Repository navigation
Editor Architecture Refactor
Status: R0–R6 DONE. This file is the source of truth. Update the progress log as items land; do not work from memory of a plan held in context.
Action-field result in one line: 58 action* fields → 3 (R2 took it to 4,
R6 converted the last convertible one). All 3 survivors are documented exceptions with
a reason. Nine of the 58 turned out to have no writer at all. Six live bugs were
found and fixed along the way — see the R2 and R6 sections.
This is the deferred follow-up to Wiki/Editor-PropertyPanel-Refactor.md. That
work fixed the editor's behaviour and deliberately did not restructure it,
because restructuring a 10,900-line pair of god-files while also hunting behaviour
bugs is how you ship two bugs at once. The defects are fixed; this document is
about maintainability.
The property-panel refactor was worth doing on its own merits and was kept separate for one reason: it could be verified. Every change there was either observable in a panel or pinned by a test. A layer split changes no behaviour at all — its only output is "the next change is easier and safer" — so bundling it with bug fixes would have made both unverifiable.
EditorPanelState carries 58 action* fields. Each one is a hand-rolled
message: a panel's WM_COMMAND writes a field, Main.cpp polls all of them once
per frame, consumes, and resets. There is no envelope, no payload type, and no
record of who asked for what.
Three concrete failure modes, all of which are latent today:
-
A stale index redirects the operation.
actionWorldGraphDeleteis an index. If the selection moves between the click and the poll — which it can, because raylib mouse picking and the Win32 message pump both run inside the frame — the delete lands on a different entity. This exact hazard had to be defended by hand for portal delete in b88:"Deleting the selected portal now clears the selection —
RemovePortalshifts every later portal down, so a retained target would have silently edited a different portal." That is a bug class, not a missing null check, and an index in a loose field cannot express the fix. - An unconsumed field fires later. Nothing tracks whether a handler ran. A field set while the handler is disabled silently executes on a subsequent frame against whatever is selected then.
- There is no way to test any of it. The channels only exist inside a Win32 message handler and a raylib frame loop, so none of it is reachable from the headless suites. Every one of the 58 is verified only by clicking.
AngelEd/Source/
├── Core/ frame loop, editor state, the bus, the dispatcher
│ ├── EditorState.hpp selection, placement mode, gizmo, viewport flags
│ ├── EditorEventBus.hpp/.cpp ← R1, raylib-free and Win32-free
│ └── EditorDispatcher.cpp routes bus events to subsystems
├── Subsystems/ domain logic, no Win32, no raw raylib drawing
│ ├── Selection.{hpp,cpp} pick / snap / gizmo targeting
│ ├── History.{hpp,cpp} undo-redo snapshots
│ ├── Placement.{hpp,cpp} spawn pawn / mesh / pickup / zone / pathnode / ...
│ ├── SurfaceOps.{hpp,cpp} SetRenderableFace / ResetSurface / AutoConvex append
│ ├── LevelState.{hpp,cpp} LevelMetadata + gametype + particles + skybox
│ └── AnimEditing.{hpp,cpp} clip CRUD, keyframes, tool-scoped vertex undo
├── Resources/ asset discovery and packaging (shared by UI + Subsystems)
│ ├── AssetScope.{hpp,cpp} the two-root (GameData)/(Packages) tree
│ ├── AssetScan.{hpp,cpp} file/package enumeration per asset kind
│ └── PackageIO.{hpp,cpp} PackIntoPackage / HotLoadPackage
└── UI/ Win32 panels. Widget code and nothing else.
├── UiCommon.hpp/.cpp CreateLabel/Button/Ctrl, CHOOSECOLORW, file pickers
├── UiShell.cpp class registration + create/destroy (unity TU)
└── <Panel>.cpp one per window, unity-included by UiShell.cpp
Dependency rule: UI may call Subsystems and Resources; Subsystems may
call Resources; nothing calls back up. Core calls down and owns the loop.
A panel that needs a domain operation posts an event; it does not reach into
PawnSystem itself.
Raylib-free and Win32-free on purpose — that is what makes the central
invariant testable headlessly, the same discipline as ZoneTypes.hpp,
GameType.hpp and SurfaceFlags.hpp.
namespace ed {
enum class Ev { SpawnPawn, SpawnMesh, SpawnPickup, ..., DeleteEntity, ... };
struct Event {
Ev kind = Ev::None;
std::variant<std::monostate, int, float, bool, std::string,
SpawnDesc, Transform, SelRef> payload;
};
}Three properties that the current design cannot offer:
-
The payload is typed.
std::variantmeans the post site is checked by the compiler against the event kind, so aSpawnZonecannot be posted carrying a filename. The 58 loose fields have no such constraint —actionSpawnZoneis anintandactionSpawnPawnis astd::string, and nothing stops them being confused. -
A selection-sensitive event carries its target, by value.
SelRefis{ SelType type; int index; }captured at post time. The stale-index bug above becomes impossible by construction rather than by remembering a guard. -
Post/drain is observable.
posted(),dropped()and a bounded queue make "the handler did not run" a testable condition instead of a mystery.
Draining stays deferred to one point per frame (inline dispatch from inside
WM_COMMAND would mutate the world while a panel is mid-layout). The safety comes
from the payload being complete, not from the timing.
Splitting one 6,100-line .cpp into 14 would mean 14 new compile rules in
AngelEd/Makefile and 14 lines plus 14 link entries in
.github/workflows/ci.yml, which builds the editor by hand. That is the
"added in one place, CI breaks silently" trap from AGENTS.md, magnified 14x,
and the Makefile and CI list can drift silently.
So UI/UiShell.cpp is a single translation unit that #includes the per-panel
.cpp fragments — the same pattern already used for raygui.c in both build
files. One new object, one Makefile rule, one CI line. The trade is
that a fragment can rely on an include it does not state; that is a real cost and
it is accepted here because the alternative is a CI that breaks invisibly.
Core/ and Subsystems/ get real .cpp files, because those are the layers
whose interfaces matter and they are far fewer per layer.
| Phase | Item | Status |
|---|---|---|
| R0 | this document | done |
| R1 |
EditorEventBus + headless suite + make test in CI |
done |
| R2 | convert the action fields, batch by batch - done | 58 |
| R3 | split Win32Dialogs.cpp into UI/ (unity TU) |
done |
| R4 | split Main.cpp into Core/ + Subsystems/ | done |
| R5 | Subsystems/Placement (close Invariant 1) + AGENTS.md | done |
| R6 | EditorState + EditorDispatcher + SurfaceOps + LevelState + Resources/ + actionApplyProperties | done |
| F | promote Subsystems/ to real translation units (Core/ excluded) | pending |
| G | Linux build for AngelEd — deferred by request | pending |
R2 is split into batches so each is independently reviewable and each deletes its fields as it goes:
| Batch | Events | Fields |
|---|---|---|
| B1 | placement / spawn — done, and it was deletion not conversion | 10 |
| B2 | world graph — done (level list deferred to B5, it is a world name, not a selection) | 8 |
| B3 | surface / CSG / portal / reload / convert — done | 8 |
| B4 | animation - done (11 events; Save/Refresh stay fields) | 12 |
| B5 | assets + level list - done | 20 |
-
A panel posts an event; it never mutates the world directly. If a
UI/file callsPawnSystem::Add*, the layer rule is already broken. -
One payload per event kind, checked by
std::visitat the handler. Noif (e.kind == Ev::X) reinterpret_cast<...>. -
A selection-sensitive event captures its target at post time. Never read
g_selinside a handler for an event that names an index. -
Every converted field is deleted in the same commit that stops writing it.
A field that is written but never read is worse than one that does not exist —
that is how
ID_PP_PORTALBROWSEandID_BTN_GT_PREVIEWsurvived. -
CI must build the editor from the same object list the Makefile uses. R3
is not finished until
ci.ymlandAngelEd/Makefileagree; the check is a diff of the two lists, not a build.
-
Converting the whole thing at once. 58 fields, 14 panels and 4,774 lines of
Main.cppin one change cannot be reviewed and cannot be bisected. -
A generic
EventBuswith string topics. The engine has no reflection and no build-time key checking, so a string bus would trade a compile error for a runtime typo — strictly worse than theaction*fields. -
Immediate (inline) dispatch from
WM_COMMAND. A panel is mid-layout when it posts; mutating the world underneath it is how you get a crash that only reproduces when you click fast. -
Moving
LevelMetadataout ofUI/. It is level state and belongs toSubsystems/LevelState; it only appears to live inUI/because the deleted Zone window used to mirror it into a global.
-
R0 — plan written after the b88 tag. Survey at that point:
Win32Dialogs.cpp6,109 lines / 14 window procs,Main.cpp4,774 lines, 58action*fields, 59 tracked files underAngelEd/.
-
EventBusis a value type with a public constructor, plusinstance(). It started as a private-ctor singleton and the test could not compile. A bus you cannot instantiate is a bus you cannot test, and testability is the entire reason this type is raylib-free — so the constructor is public andinstance()is only the production convenience. -
Ev::Countis a sentinel, not an event, and the suite assertsEv::Count == named + 1. That is the anti-drift mechanism: add anEvwithout adding it tokEventsin the test and the suite fails. My first attempt hardcoded "39 values" and was wrong (39 events + None = 40), which is exactly the kind of stale constant the sentinel removes. -
A wrong-shape payload read logs and returns an inert value rather than
aborting. Crashing an editor frame is worse than a logged mismatch, and the
alternative —
reinterpret_caston the variant — is the bug this design exists to prevent. -
CI now runs
make test. It previously ran onlyworldcheck, which is whytest_ozls_writerandtest_surfacesat unrun. Two details that matter:-
make testinvokes each suite as-./suite, so it continues past failures and its exit code is useless. The new step tees the log and greps^[[:space:]]*FAIL— one pattern covers every suite's format. - A compile failure is still fatal, because only the
./suiteinvocation is--prefixed; the compile rules are not. So the grep covers execution and make covers build, with no gap.
-
- CI runs the suites in the Linux job only. The Windows job builds the editor, which is where AngelEd compilation is actually verified; the bus itself is platform-independent.
Auditing the writers of every action* field before converting any of them found
that the entire placement batch was dead. For all nine actionSpawn* fields
the only assignments anywhere in AngelEd/Source/ were the handlers' own resets to
their defaults:
$ grep -n 'actionSpawn\w*\s*=[^=]' AngelEd/Source/*.cpp AngelEd/Source/*.hpp
Main.cpp:4661: g_editorPanels.actionSpawnParticleEmitter = false;
Main.cpp:4672: g_editorPanels.actionSpawnPathNode = false;
...
So ~110 lines of handlers in Main.cpp could never run. Placement in fact happened
synchronously in SpawnSelectedPawnTreeItem, which covered all nine types - pawn,
pickup, player start, emitter, zone, mesh, particle emitter, path node, wind zone -
using spawnPos.
CORRECTION (made in R5). This section originally added: "The dead handlers would also have placed at
MainCamera.targetinstead of the ghost position, so they were not merely redundant but wrong." That was false.g_editorPanels.spawnPosis assigned fromOTEditor.MainCamera.targetevery frame, so there is no separate ghost position - the camera aim point is the placement point. The dead handlers would have been behaviourally identical, not wrong.It mattered, because that sentence was the strongest stated reason to keep placement synchronous. With it gone, synchronous placement had no position-fidelity argument left - which is what made routing placement through the bus defensible at all. R5 therefore deferred placement by one frame deliberately, having established there was no second position to lose.
This is why the batch is an audit first and a conversion second. The plan
assumed 58 live channels; it is closer to 49. Ev::Spawn* still earns its place —
SpawnSelectedPawnTreeItem reaching into PawnSystem directly is exactly the
UI→Subsystems dependency R3/R4 removes — but its first real users arrive there,
not here.
actionApplyLight was the only writer of the legacy light apply handler in
Main.cpp, and LightPropsProc was that writer. Deleting the Light Properties
window in b88 therefore orphaned ~27 lines. It was not caught at the time because
"a handler nothing can reach" produces no build error and no test failure.
It was also already a strict subset of the panel's tgtType == SelType::LIGHT
branch (Main.cpp:3940), which additionally handles position, name and target and
clamps type/effect defensively. Removed, along with its 12 light* backing
fields — which nothing else referenced.
Lesson worth keeping: removing a producer must include a check that its consumer went with it.
grepfor the handler, not just the field.
Action fields: 58 → 49 (9 actionSpawn* + actionApplyLight removed).
Nine fields became four events (SelectEntity, ApplyProperties, DeleteEntity,
DuplicateEntity), and the conversion surfaced a defect that was live in the
editor right now:
g_editorPanels.actionWorldGraphDelete = e.selIndex; // writer
...
if (g_editorPanels.actionWorldGraphDelete >= 0) {
if (g_sel.type != SelType::NONE) DeleteSelectedEntity(); // reads g_sel, NOT the indexThe index was written and never read. Delete and Duplicate acted on whatever
g_sel was, which was correct only because the select field happened to be
written in the same WM_NOTIFY — an ordering coincidence, not a guarantee. The
same batch found:
-
Two index spaces in three near-identically named fields.
actionWorldGraphPropertiesheld a ListView row index whileactionWorldGraphDelete/Dupheld an entity index. -
actionWorldGraphPropertieswas never read at all. Its handler just calledOpenPropertiesForSelection(), i.e. acted ong_seltoo. -
One message spread over five fields.
actionSelectFromGraphplus itsType/Name/Pos[3]satellites were written together and read together with nothing coupling them, so a handler could read a new index against the previous frame's type and position without noticing. Now oneed::Selection.
AdoptSelection() takes the target from the event rather than from g_sel, which
makes the ordering irrelevant instead of merely currently-fine.
-
ToBusKind(SelType)is a switch, not astatic_cast. The two enums have identical values so a cast would work — and would keep compiling after someone adds a 15thSelType, silently mis-dispatching every event for it. A switch with noelseturns that omission into a compile error. It lives inSelType.hpp, not in the bus, because that is the only place both enums are visible. - The drain happens BEFORE dispatch, and into a file-static vector. A handler that opens a panel can post further events; draining while iterating the live queue would dispatch those in the same frame.
-
e.selType < 0is mapped toSelKind::None.sel::DEF_ONLY(-1) is a Script-Manager sentinel, not aSelType, and it means "no live target".AdoptSelectionthen rejects it, so Properties/Delete on such a row does nothing rather than acting on the previous selection. -
Unhandled events are counted (
g_editorUnhandledEvents), not ignored, so a batch that posts an event nobody handles shows up as a counter rather than a click that silently does nothing. Should be 0 once R2 completes.
actionLevelListOpen / actionLevelListLink were not converted here: they
carry a world name, not a selection, and belong with the other asset/world
operations in B5.
Action fields: 49 → 40. test_editorbus 46/46.
Converted to ApplySurface, ResetSurface (both a new ed::SurfaceEdit),
CsgPlace/CsgCommit (a new ed::CsgIntent), DeletePortal, ReloadMesh,
ConvertToAnimated.
Two more live defects fixed on the way:
-
Reloadcould reset an unrelated object's cache. The handler readg_sel.indexand only checkedg_sel.type == SelType::MESHafter indexing, so a Reload pressed while something else was selected calledGetMeshObject()with a foreign index. It now carries aSelRefand guards on the kind as well. -
Convert to Animatedconverted whatever was selected at drain time rather than the mesh whose button was pressed. Same shape of bug as the WorldGraph one.
-
actionApplyPropertieswas left as a field here, and converted in R6. At this point there was no target to capture: the properties panel ownedpropsTargetType/propsTargetIndexitself and was both the only writer and the only reader, so an event would add a queue hop without adding a payload that could go stale. It was scheduled to convert "in R4, when the panel stops owning the target directly" — R4 did not do it, and R6 did. See the R6 section: the values stay live-read on purpose, and only the target travels. -
SurfaceEditcarries the mask, andResetSurfaceposts a mask too. The viewport'sIDM_SURFACE_RESETroute previously set a bare bool, so it reset only the last-clicked face and ignored the Shift multi-selection the dialog title advertises ("(N Selected)"). It now rebuilds the mask fromg_selectedSurfaces, which is what both routes should have used. -
Surface events are drained in their own pass (
g_editorSurfaceEvents), not the sharedg_editorFrameEvents. Those handlers are the ones moving toSubsystems/SurfaceOpsin R4, and a separate pass means that move does not have to reorder the selection dispatch. -
CsgPlaceandCsgCommitstay separate kinds. "Arm a primitive" resets the ghost to a default box at the camera; "commit the ghost" reads the ghost as it is. One merged event would either lose the reset or commit a brush nobody positioned.
Adding DeletePortal made test_editorbus fail immediately:
FAIL Ev::Count == named events + None (every Ev has a row above)
That is the anti-drift mechanism from R1 doing exactly its job on the first real
use — a new event kind with no test row is a failing suite, not an untested
path. It is the direct successor to ID_PP_PORTALBROWSE and ID_BTN_GT_PREVIEW,
which sat declared, created and never handled for a long time.
Action fields: 40 → 32. test_editorbus 55/55.
Deleting the CSG handlers by line range left one } behind (the block's close was
at 4769, the cut ran to 4768), which closed the render loop early. The symptom was
a cascade of ~15 unrelated "expected constructor" errors 1,700 lines later. A
five-line brace-count loop after every range deletion found it immediately, and is
now part of the routine for the remaining batches.
Eleven commands became ed::Ev::Anim* carrying a new ed::AnimIntent (mesh id,
clip name, playhead, fps/loop). Another live staleness bug fixed:
Delete Clip deleted a different clip than the one clicked. The handler read
animClipNamefrom live panel state at drain time. A clip list re-selects on every click, so this was easy to trigger — click Delete, and the clip that goes is whatever the list holds when the frame drains. The same applied to Add/Delete Key (animTime) and ApplyClipMeta (animFps/animLoop).
Two fields deliberately stayed, and this is the batch's main judgement call:
| Field | Why it is not an event |
|---|---|
actionAnimSave |
An intra-frame chaining signal. NewClip, DeleteClip, AddKey, DeleteKey and ApplyClipMeta all set it from inside their own handlers, and the file write consumes it later in the same frame. Queueing it would delay every save by a frame for no benefit — and the write is part of the command completing, not an independent user action. |
actionAnimRefresh |
A dirty flag. Set by 7 sites including handlers; consumed by RefreshAnimPanel(). It means "the panel needs re-reading", not "the user did something". |
Treating these as events would be the "convert everything" reflex applied to two
signals that are not messages. test_editorbus asserts their absence from the
enum, so a future "fix" for that asymmetry has to be deliberate.
AnimIntent carries meshId, but AnimTarget(), AnimSnapshotPush(), AnimUndo
and the snapshot stack all resolve the current target internally, so the id
cannot yet be threaded through them. Rather than leave that silent,
ApplyAnimIntents detects the mismatch and skips + logs the command:
EditorLog("Anim: command skipped, target moved (event=%d was for %d, now %d)", ...)
Subsystems/AnimEditing in R4 threads the id properly; until then a moved target
is a visible no-op instead of an edit to the wrong mesh.
- Ctrl+Z / Ctrl+Y now posts the same events the toolbar does, so keyboard undo is not a second code path with its own staleness behaviour.
-
PostAnimIntent()reads fps/loop off the controls, not the mirrored panel fields, so an intent cannot be built from values the panel has already moved past. -
All eleven handlers moved out of
main()intoApplyAnimIntents()— the first real step towardSubsystems/AnimEditing, and the reason animation drains into its owng_editorAnimEventspass.
Action fields: 32 → 23. test_editorbus 63/63.
New payloads: PlacementRequest, TextureApply, HeightmapDesc. Events:
BeginPlacement, ApplyTextureToModel, PlaySound, StopSoundPreview,
GenerateHeightmap, RefreshModelBrowser, OpenWorld, LinkWorld.
One more live staleness bug:
The Pickups panel could place the wrong pickup. It sent a list index and the main loop resolved it with
LegacyPickupType(idx)at drain time. A panel rebuild between the click and the frame placed a different def. The panel now resolves the name itself — the button IDs are minted from the sameFindByType(EntityType::PICKUP)order the panel builds its list from, so the lookup is exact — and the name travels in the event.
The six actionHm*/actionHeightmap* fields collapse into one HeightmapDesc;
four of them were read at drain time, so a panel edit between click and frame
generated the heightmap from values the panel had already moved past.
The prefix was lying about these — they are live state the loop re-reads every frame, not messages:
| Was | Now | Why |
|---|---|---|
actionSoundVolume |
previewSoundVolume |
Volume slider; re-applied every frame while the preview plays |
actionSoundLoop |
previewSoundLoop |
Same |
actionSoundCategory |
previewSoundCategory |
Same |
actionRefreshBrowser |
refreshModelBrowser |
Dirty flag, exactly like actionAnimRefresh
|
Only the path became an event. That split matters: a category switch between the click and the frame would otherwise preview from the wrong list, which is precisely why the path travels and the sliders do not.
| Remaining field | Why it is not an event |
|---|---|
actionApplyProperties |
Converted in R6 → ed::Ev::ApplyProperties carrying a SelRef. The row below was correct for R2 and was out of date by one phase: the precondition it named was met in R6. |
actionApplyTextureToSel |
Reads g_sel/activeTexturePath at drain time — normally the bug this refactor kills, but both writers are context-menu commands and TrackPopupMenu is modal, so nothing else pumps input and the selection cannot move. Capturing a Selection would mean duplicating the brush renderable resolution (which depends on g_sel.pos to tell a renderable index from a collision-volume index) for no behavioural gain. |
actionAnimSave |
Intra-frame chaining signal. The anim handlers set it themselves; the file write consumes it in the same frame. Queueing delays every save a frame. |
actionAnimRefresh |
Dirty flag consumed by RefreshAnimPanel(). Means "re-read the panel", not "the user did something". |
58 → 4. Nine of the original 58 turned out to have no writer at all (B1), so
the number of channels that actually needed converting was ~45, not 58. Nine fields
that were never messages were renamed to stop pretending otherwise.
Live bugs found across R2: five. WorldGraph Delete/Duplicate read g_sel
instead of their own index; Reload indexed GetMeshObject() with a foreign index;
Convert-to-Animated converted the wrong mesh; the Pickups panel resolved an index
at drain time; and the b88 Light-window deletion orphaned its apply handler. All
fixed.
Split into the same unity-TU shape as R3, so Main.o stays Main.o and no build
wiring changed at all — the Makefile and the hand-rolled CI build were untouched.
AngelEd/Source/
Main.cpp the TU: preamble + 11 fragment includes + main()
Core/EditorShell.hpp includes, shared editor globals, local types, fwd decls
Core/EditorShell.cpp view presets, panel toggle shims, world dir, scene reset
Core/EditorLog.cpp AngelEd.log appender
Core/EditorMenus.cpp native menu bar
Subsystems/Selection.cpp raycast, surface-face pick, gizmo snap, adopt,
delete, duplicate
Subsystems/WorldIO.cpp LoadWorldDocument + name helpers
Subsystems/OzoneExport.cpp ExportToOzone, Save, File menu, texture apply
Subsystems/WorldGraphBridge.cpp UI accessor surface + Win32 menu dispatch
Subsystems/AnimEditing.cpp vertex-keyframe editing + anim event handlers
Subsystems/EntityOps.cpp AutoConvex proxy append, brush CSG commit
Subsystems/History.cpp OZONE-snapshot undo/redo + ApplyMapProperties
Subsystems/Placement.cpp Spawn* handlers: the world mutation
EditorEventBus.{hpp,cpp} shared event contract
Editor.hpp, EditorIcons.{hpp,cpp}, SelType.hpp
UI/ see R3
Almost every helper in the old file is static, and they share file-scope globals
(g_sel, g_placeMode, g_histUndo, g_selectedSurfaces, g_lastPreviewSel,
g_editorPanels, ...). Promoting them to real translation units means externalising
~40 symbols and hoisting that shared state into a header — which changes linkage
and initialise order. That is the one class of change in this whole refactor that
make test cannot see, because AngelEd has no headless harness. A unity split keeps
the declaration sequence byte-identical and the risk at zero.
Promotion to real TUs is the obvious R5 and is now unblocked: the cut points and the shared-state inventory are both known.
The fragments carry no forward declarations of their own, so several statics are
declared in the fragment above their user: g_editorLog lives at the top of
EntityOps.cpp and is used in EditorLog.cpp; g_originalWndProc and
EditorWndProc are declared in WorldGraphBridge.cpp and used in EditorMenus.cpp.
Grouping the includes by layer ("core before subsystems") reads better and does not
compile — it was tried, produced four errors, and Main.cpp now says so at the
include list.
UI/UiCommon.hpp includes Core/EditorEventBus.hpp. That is not an accident and not
a violation to fix by moving the file: UI uses the ed:: vocabulary throughout
(Ev, Selection, SelKind, SelRef, SurfaceEdit, CsgIntent, AnimIntent,
PlacementRequest, TextureApply, HeightmapDesc). The bus is the contract all
three layers speak — producers publish, Main.cpp drains, UI dispatches — with the
queue in its .cpp. The layer rule is therefore stated as: UI may reach
EditorEventBus.hpp and nothing else upward.
Line-multiset comparison against the pre-split file: 237 lines added (banners, the
include-order comment, the re-pathed includes) and exactly 16 removed, every one
of them an #include whose path changed because the preamble moved from Source/ to
Source/Core/. Zero code lines lost or altered.
The comparison needs
-Encoding UTF8on both sides. PowerShell 5.1'sGet-Contentotherwise defaults to the ANSI codepage and mangles every em-dash in the file's comments, which shows up as ~40 phantom "removed" lines and looks like data loss.
R3 and R4 were file surgery. R5 was the first change in this document that alters behaviour, and it exists to close the invariant the document opens with:
A panel posts an event; it never mutates the world directly. If a
UI/file callsPawnSystem::Add*, the layer rule is already broken.
It was broken. SpawnSelectedPawnTreeItem in UI/Panels/PawnPanel.cpp called
PawnSystem::Add* eight times and ZoneManager::AddZone once, from inside a
WM_COMMAND handler.
| Before | After | |
|---|---|---|
| Panel does | validate + mutate | validate + post |
| Position source |
g_editorPanels.spawnPos read at click |
SpawnDesc::at, captured at post |
| Mesh path | resolved by index at click | resolved by the panel, path travels |
| Undo |
none - placement was the one mutation with no HistoryPush
|
HistoryPush() first |
SpawnDesc gained Transform at (position, plus zone/wind-zone half-extents and
mesh/playerstart yaw), bool skeletal and int lightType; Ev::SpawnLight was
added for the three light tags, which had no event at all. Transform had to move
above SpawnDesc to be nested.
-
The
Spawn*seam already existed and was fully tested - with zero users. All nine kinds andSpawnDescwere exercised only bytests/EditorEventBus.test.cpp. The deadactionSpawn*fields had been deleted in B1 and the replacement was left to land here, which is exactly what the B1 note said would happen. -
ev.spawn()is the shape check. The first draft ofApplyPlacementSpawnsusedstd::holds_alternative<ed::SpawnDesc>(ev.payload())- which does not compile, the member isdata. But the better outcome is that it is unnecessary:spawn()already logs and returns an inertSpawnDescon a shape mismatch, which is the documented anti-reinterpret_castmechanism. A second, weaker guard doing the same job would be the wrong fix.
-
Placement defers by one frame. It used to happen inside
WM_COMMAND; it now happens at the next drain. Safe because the panel validates synchronously (its failureMessageBoxneeds the return value, and a queued event cannot report back) and because nothing in the handler reads live panel or selection state. - Placement is now undoable. This is a fix, not a regression: previously a placed entity could not be removed with Ctrl+Z while delete, duplicate and surface edit all could.
-
Spawn*events with no handler incrementg_editorUnplacedEventsrather than being ignored - the failure mode that kept the nineactionSpawn*fields invisible. -
Invariant 1 grep now passes: the only
ps.Spawnleft inUI/is inside a comment describing the old code.UI/still calls twelvePawnSystemaccessors, all reads (GetDefs,GetMeshObject,GetPickups, ...), which the invariant permits. -
make testgreen includingtest_editorbus77/77 (was 73/73). - The
Ev::Countsentinel failed the suite the momentSpawnLightwas added, before its row existed. Working as designed.
- Manual passes: DONE for R3-R8. The user exercised the editor by hand after each round: R3-R5 (placement), then R6-R8, where R6 deferred the properties apply by a frame, R7 changed how both asset browsers enumerate, and R8 fixed brush selection. This is the load-bearing check for the whole document. A layer split changes no behaviour and so has no headless signal; the line-multiset diff proves the text is unchanged, not that the editor still works. Both matter, and the second is the one that actually caught anything.
-
What a manual pass caught that nothing else could: both of R8's reported bugs
(Delete crashing, brushes reappearing on click) were PRE-EXISTING - the delete branch
is byte-identical at
b89- and had been sitting inAngelEd.logas a churn ofBrush idx=102, 15, 14, 13, 11, 10, 9, 7for one point on screen. No suite covers picking and there is no honest way to add one: the defect was two index spaces behind oneSelType, which is only observable by clicking. -
DONE in R6 —Resources/layer: never created.AssetScopeandPackageIOare real translation units with headers of their own, andtests/AssetScope.test.cpp(45 checks) is the first automated coverage the asset browser has ever had.AssetScanis the one exception and is deliberately not done:ScanFilesAndPackagestakes aResourceEntry, which holds anHBITMAP, andScanModelBrowserFilesonly ever fillsg_editorPanels.modelEntriesand thenSendMessages the panel. Moving either as-is would put a Win32 type or editor-UI state insideResources/. The honest version extracts one enumeration primitive returningAssetScopeItemand leaves the state write and refresh in the panel — a refactor with behaviour, so it needs its own commit and its own tests. -
DONE in R6.Core/EditorDispatcher.cppandCore/EditorState.hpp: never created. -
DONE in R6. Note the plan was wrong about where they were folded: surface code was never inSubsystems/SurfaceOpsandSubsystems/LevelStatewere folded.EntityOps.cpp— that file has never contained any. -
DONE in R6, along withLevelMetadatais still declared inUI/UiPanels.hpp.ParticleType, which was only in a UI header becauseLevelMetadatawas. Its accessor had been implemented insideUI/Panels/LevelState.cpp— subsystem state living in a UI panel. -
DONE in R6. 58 → 3.actionApplyPropertiesis still a field. -
actionApplyTextureToSelremains a field by the documented modal-TrackPopupMenuargument, which is sound.actionAnimSaveandactionAnimRefreshlikewise. All three are final unless their reasoning changes. -
Promotion of
Subsystems/to real translation units (phase F,Core/excluded). -
Linux build for AngelEd (phase G) — deferred by request. Scope already sized: 2
missing UI stubs, ~6 guard blocks, delete
AngelEd/Makefile:38's$(error), add AngelEd to the Linux CI job. R6 reduces this cost —Resources/moved shared logic out of the Win32-only UI translation unit into portable TUs.
-
make MODE=debug OTENGINE AngelServ AngelMaster ozpackandmake -C AngelEd MODE=debugclean at every phase. -
make testgreen — includingtest_editorbus, which does not exist until R1. -
The two AngelEd object lists were diffed, as required — and they differ by exactly
one entry, legitimately.
ci.ymllinksOTCustom_stub.o;AngelEd/Makefile'sEDITOR_OBJShas no such object because the Makefile builds a real sharedCustom.soand links-l:Custom.soinstead. Both compile the same stub body. This is pre-existing and correct, but it means the invariant "the two lists must be equal" is imprecise as written — a literal diff will always show this one entry and will read as a regression to whoever checks next. Compare with this exception named, not by equality. - Manual pass: DONE. Every menu entry, toolbar button, panel context menu and drag was exercised in the editor after R3/R4/R5; the user confirmed placement works. This was the main risk in the document, because a layer split changes no behaviour and so has no headless signal at all — the line-multiset diff proves the text is unchanged, not that the editor still works. That gap is now closed by hand rather than by machine.
Five pieces the Design section specified and R3/R4 did not deliver. Main.cpp went
from 5,039 lines (start of R4) to 2,184.
AngelEd/Source/
Main.cpp the TU: preamble + includes + main()
Core/EditorShell.hpp the include list, and nothing else
Core/EditorState.hpp menu IDs, event buffers, selection / mouse / gizmo state
Core/EditorDispatcher.cpp DispatchFrameEvents() - the 18 frame-pass kinds
Core/EditorLog.cpp Core/EditorShell.cpp Core/EditorMenus.cpp
Core/EditorEventBus.{hpp,cpp}
Subsystems/Selection.cpp EntityOps.cpp SurfaceOps.cpp WorldIO.cpp
Subsystems/OzoneExport.cpp WorldGraphBridge.cpp AnimEditing.cpp
Subsystems/History.cpp LevelState.{cpp,hpp} PropsApply.cpp Placement.cpp
Resources/AssetScope.{hpp,cpp} PackageIO.{hpp,cpp}
UI/UiCommon.hpp UiPanels.hpp UiShell.cpp Panels/*.cpp
-
EditorShell.hppwas three things wearing one filename. The include preamble, the editor state, and thirteenRaycastTest*picking functions whose only caller isSelection.cpp:38-50. Picking logic sitting in a state header is the whole reason that file was 420 lines and unreadable. -
The 240-line dispatch switch left the render loop.
main()now reads as a frame loop: drain, dispatch, four more drains, apply. -
SurfaceOpsis the name the plan always specified. The plan claimed it already existed folded intoEntityOps.cpp. It did not —EntityOps.cppholds onlyAppendAutoConvexForSelectionandCommitBrushRenderable, and has never contained any surface code. The plan was wrong about where the code was, not just about its name. -
LevelMetadatawas subsystem state implemented inside a UI panel. Its accessor lived inUI/Panels/LevelState.cpp; its declaration inUI/UiPanels.hpp. The plan's "Ruled out" section had this right years before the code did. -
Resources/shared code lived inside its consumers.BuildAssetScope,PackIntoPackageandHotLoadPackagewere all implemented inUI/Panels/TexturePanel.cppwhileUI/Panels/ModelPanel.cppcalled them. They were reachable only because the UI layer is one translation unit — the moment it became real objects,ModelPanel.cppwould have stopped compiling. A shared contract with no header is not a contract.
Resources/AssetScan. ScanFilesAndPackages takes a ResourceEntry, which holds an
HBITMAP thumbnail; ScanModelBrowserFiles returns nothing and only clears, fills and
dedups g_editorPanels.modelEntries before SendMessageing the panel. Moving either
wholesale would put a Win32 type, or editor UI state, inside Resources/.
The honest version extracts one enumeration primitive returning AssetScopeItem —
which is also what BuildAssetScope consumes, so both browsers share it — and leaves
the state write and the panel refresh in the panel. That is a refactor with behaviour in
it, not a pure move, so it does not get the line-multiset verification; it needs its own
commit and its own tests.
The handler read 87 distinct g_editorPanels.prop* fields, of which about six apply
to any given SelType. A capturing payload would snapshot 87 to write 6. So the values
stay live-read and the event carries the SelRef only.
That is safe rather than a half-finished conversion, because the staleness this refactor
kills is an index that ends up naming a different entity than the one clicked. It
cannot happen to a value the user just typed: the panel is not modal, but between the
Apply click and the drain — the same frame — nothing pumps input. Same argument that
keeps actionApplyTextureToSel a field.
What it does buy is the part that was genuinely wrong. The handler used to read
propsTargetType/propsTargetIndex from live panel state, so whichever row the panel
happened to be showing at drain time decided what got written.
ToSelType(ed::SelKind) was added as the reverse of ToBusKind — also an exhaustive
switch, not a static_cast, for the reason already recorded on the forward direction.
propsTargetHasRotation stays a live read at three sites. It is a per-type capability
flag ("does this kind have a yaw"), not a target; conflating them would reintroduce the
bug where an Apply silently reset playerstart yaw to 0.
Action fields: 58 → 4 → 3. The three survivors are final unless their reasoning
changes: actionApplyTextureToSel (modal TrackPopupMenu), actionAnimSave
(intra-frame chaining), actionAnimRefresh (dirty flag).
Resources/ is the one place in this refactor where extra wiring was accepted rather
than avoided — 3 Makefile rules, 3 ci.yml entries. Everywhere else a unity fragment
keeps the object count at one. The exception is justified because Resources/ is a
genuine two-consumer interface, and its whole defect was having no header.
The object lists were diffed as this document requires: Makefile 36, ci.yml 37,
agreeing on both new objects. The single difference is OTCustom_stub.o, which is
intentional (CI links a stub where the Makefile links -l:Custom.so) and is now named
in the Makefile comment so the next person does not read it as a regression.
-
A range-delete mangled
main(), twice: once from an off-by-one (the frameforis at 1662 and closes at 1902, not 1663–1903), and once from a miscomputed splice range. Both were reverted withgit checkoutand redone from exact brace-balanced ranges. -
The first generated
EditorDispatcher.cpphad a duplicatedforand balance +1, which surfaced as a parse error in a file three includes later. Both extractions now assertbalance == 0before writing. -
git checkout -- AngelEd/Makefile, intended to undo a temporary probe rule, reverted the uncommittedResources/wiring too. Re-applied and rebuilt to prove it. -
PostApplyPropertieswas inserted below its three call sites — the unity-build property again: fragments carry no forward declarations of their own. -
The first
test_assetscoperun was 43/45, both failures a bug in my own test helper (a synthetic parent node leaked"(GameData)/"into every expected path). The code was right; the test was wrong.
Every one was caught by verifying after the fact rather than assuming. That is the only reason any of them cost minutes instead of a bad commit — and the reason a reviewer can read these entries as evidence the process works, not as an excuse.
EnsureProjectRoot() (Core/EditorShell.cpp) is the first statement in main(),
before g_config.Load, before PackageAssetLoader::Init, before the world load. Three
rules, in order:
- cwd already contains both
GameData/andSystem/; - cwd's final component is
system(case-insensitive) and its parent is a root - this is what the run scripts set up, and is the only case the old code handled; - walk up to four levels from the executable (
GetModuleFileNameA).
It never fails hard - the editor can still open an explicitly-named world from a GameData-less layout - but it logs the directory it settled on, and on failure logs the cwd it tried.
Why the generalisation was needed. A real session reported two errors that had no visible connection:
WARN: GameData/Global/PawnDefs not found, using hardcoded defaults
Skybox: could not load 'GameData/Global/sky/sky_noon.dds'
Both are downstream of the cwd. Almost every asset path in the editor is a relative
literal - GameData, System/AngelEd.ini, System/Data - so a wrong launch directory
does not fail loudly, it fails selectively and confusingly. The old heuristic handled
only the directory the run scripts happen to use, so a shortcut with a different "Start
in", a copy of the tree elsewhere, or a debugger all landed in the broken state.
PackageAssetLoader::Init's missing-System/Data branch was a bare return. That is
the line that made this undiagnosable: every packaged asset vanished with nothing in the
log. It is now an OZ_WARN naming both the path and the cwd.
Verified by launching the built editor from System/ and from an unrelated temp
directory: both recover, log Project root: ... (from System/) / (from exe), load all
four pawn defs, and leave no stray log in the wrong cwd.
Do not reintroduce a path that exists in only one layout. The no-argument default
world was "../GameData/World.ozone", which resolves in no layout at all: worlds live
at GameData/Worlds/<Name>/World.ozone, and the path also contradicted the
System/AngelEd.ini load eight lines below it. FirstAvailableWorld() takes the
sorted-first world that actually has one, so the choice is deterministic rather than
dependent on directory-iteration order.
The Explorer list is derived from PawnSystem / ZoneManager / OzoneLoader, and it
was built exactly once - in WM_CREATE, from CreateAllEditorWindows, which runs
before the world is loaded and before PawnDefs register. Opening it on a fresh
session showed one Map (level) row and stayed that way until Refresh was pressed.
Worse, each row's lParam is a selIndex into the very vector the row was built from.
A delete or duplicate shifts every later row, so a second right-click acts on the wrong
entity until the next manual refresh. The single auto-refresh that existed sat inside the
props-apply block: it fired for every Apply regardless of what changed, and missed every
other mutation.
The rule now:
-
MarkWorldGraphDirty()(inCore/EditorPanelState.hpp) is set at the choke points - each event drain gated on non-empty, the Enter-key placement path that bypasses the bus,AppendAutoConvexForSelection,HistoryRestore, and world load. - It is consumed once per frame after the last drain in
Main.cpp, so a mutation is never reflected a frame late. -
ShowWorldGraph(true)refreshes unconditionally; a hidden panel costs nothing because the consume is gated onshowWorldGraph.
Setting a flag is not a mutation. That is what keeps the layer rule intact: UI/ still
posts and never mutates, Subsystems//Core/ only set the flag, and the single
RefreshWorldGraph() call lives in Core's frame loop - Core reaching down into UI, the
legal direction. Do not "fix" this by calling RefreshWorldGraph() from a Subsystems/
handler.
Two calibrations worth keeping:
-
Gating the drains on
!empty()is load-bearing. Marking unconditionally rebuilt the list on every frame - measured at 19 rebuilds/second on an idle open panel. -
The gizmo release is gated on
g_gizmoHistPushed, the flag meaning a transform was actually pushed to history. Ungated, every plain click refreshed the list, because a pick is also a release.
Verified by driving IDM_WORLD_GRAPH and querying the ListView directly: 131 rows
populated on open with no manual refresh, and zero rebuilds while idle.
ID_PAWN_SHOW_HIDDEN and ID_PAWN_TREE were both 105. It happened to work only because
the treeview emits WM_NOTIFY and the checkbox WM_COMMAND - but neither could be
addressed with GetDlgItem, and the checkbox's handle was discarded at creation, so its
state could not be seeded or reset.
The checkbox also had a hard-coded rect and was never passed to LayoutPawnMgr, so on
WM_SIZE it stayed at y=228 while the tree and the three buttons re-flowed to
y = 41 + treeH - a visible 9px desync at the default size. It now has a distinct ID, a
kept handle seeded from g_editorPanels.showHidden, and a place in the layout; the default
pawnMgrPos is 480 wide so it no longer ends 6px from the window edge.
When adding a control to a panel that has a layout function, add it to that function.
A hard-coded rect in WM_CREATE is a bug the first time the window is resized.
ScanTextureBrowserFiles() now delegates to Resources/AssetScan's ScanAssets rather
than carrying a third copy of the GameData + package walk. It also stops re-deriving the
dedup policy from !IsPathFile(): ResourceEntry carries fromPackage, stated by the
scanner that knows which list the entry came from, exactly as AssetScopeItem already did.
Two resource bugs fixed alongside:
-
Thumbnails were built eagerly, one per texture in GameData, on every rescan -
thousands of GDI bitmaps and a visible memory spike at panel-open time, for cells that
were off-screen or never scrolled to.
BuildTextureThumbnail()is now called from the paint loop on first use. Off-screen cells are alreadycontinued, so scrolling pays only for what it shows. -
ScanTextureBrowserFilescleared a vector owning anHBITMAPper entry without deleting them, leaking one bitmap per texture per rescan - and a rescan runs on every window activation. The free loop now runs before theclear().
The eager loop's tex.path.substr(tex.path.rfind('.')) was also unguarded: substr(npos)
throws when a path has no extension, reachable for a hand-built package key.
Promoted to real translation units (AngelEd/Makefile rule + ci.yml entry each):
SurfaceOps, Placement, PropsApply, LevelState, History
Still unity fragments in Main.cpp, with the seam that blocks each:
| Fragment | Blocker |
|---|---|
Selection |
reads/writes g_editorPanels throughout; entangled with the Core dispatcher |
EntityOps |
CommitBrushRenderable + AutoConvex reach into OzoneLoader and UI statics |
WorldIO |
uses ClearScene/Clear* statics declared across Core fragments |
OzoneExport |
calls UI file dialogs (ChooseOpenWorldFile, ChooseSaveWorldFile) and Core statics (g_documentPath, SetWorldDirectory, g_pendingNew) - deliberately not promoted, since moving it either way is a violation |
WorldGraphBridge |
the EditorWndProc menu dispatcher, which owns Core statics |
AnimEditing |
cross-state access to the animation target and undo stacks |
Object lists verified in step: Makefile 44 / ci.yml 43, agreeing on all 43 editor
objects, the sole difference being OTCustom_stub.o.
It was deleted in R3. It was the single 8k-line file that held every Win32 native
panel; it is now UI/Panels/*.cpp, with UI/UiShell.cpp as the one unity TU that
#includes them, and the shared two-consumer code moved to Resources/.
Mentions of Win32Dialogs.cpp inside the progress logs above are left as written -
they record what was true when the work was done, and rewriting them would falsify the
record. The live pointers were fixed in AGENTS.md and Wiki/Engine-Overview.md:
| Symbol | Now lives in |
|---|---|
BuildAssetScope, AssetScopeItem, AssetScopeNode
|
Resources/AssetScope.{hpp,cpp} |
PackIntoPackage, HotLoadPackage
|
Resources/PackageIO.{hpp,cpp} |
FillDefBlock, kWeaponStats/kPlayerStats/kPawnStats/kLightStats, the Props*Scroll layout |
UI/Panels/PropsPanel.cpp |
Angels95 - AngelTech Engine | repository | 8ea8239
Built on raylib 5.5.