Repository navigation
Editor PropertyPanel Refactor
Status: P0–P4 IMPLEMENTED, awaiting manual verification. All five targets
build and all 13 test suites pass, but the interactive checks listed at the bottom
have not been run — AngelEd is Windows-only and not in the test link, and the
SurfaceMaterial change alters pixels, so "it compiles" is not "it works". This
file is the source of truth for the work. Update the progress log as items land;
do not work from memory of a plan held in context.
The larger Core/Subsystems/UI/Resources split plus an EditorEventBus was
deliberately deferred: the defects that motivated this work are fixed without
it, and it remains the right next step for maintainability, not for correctness.
- Remove the duplicate View → Zone Properties and View → Light Properties windows. Both duplicate the working Entity Properties panel, and neither applies the settings you edit in it.
- Replace the level-level state they uniquely owned with a Map row at the top of the WorldGraph list, which opens the same Entity Properties panel.
- Give per-zone fog / ambient / reverb a real editor surface — it currently has none, even though it is what the game actually reads.
- Re-sort the properties panel visually and fix the dead controls found in it.
ShowLightProps (AngelEd/Source/Win32Dialogs.cpp:3764) opens the Entity
Properties panel and returns early when a light is selected. That path is fine.
When no light is selected it instead shows the legacy window — but
g_editorPanels.lightPropTarget is assigned in exactly one place in the whole
tree (Win32Dialogs.cpp:3771), immediately before that early return. So the only
case the legacy window is reachable is also the case where its target is -1:
-
PawnSystem::GetLight(-1)matches nothing → returnsnullptr -
WM_USER + 50populate handler (:3843) skips its wholeif (ln)block, so the panel showsWM_CREATEhardcoded defaults -
Main.cpp:4661'sif (LightNode* ln = …)is false, so the entire write block is skipped — includingHistoryPush()and theEditorLogconfirmation
It also has no Name and no Position/Target rows, so it could not round-trip
through the OZONE export even if it worked.
Its Apply handlers only set boolean flags on a file-static g_zoneProps
(Win32Dialogs.cpp:2676). The consumer (Main.cpp:2609-2665) then:
| Field | Fate |
|---|---|
fogR/G/B, fogDensity, fogIntensity
|
reach LitFogShader uniforms |
fogStart, fogEnd
|
never consumed anywhere in the repo — dead controls |
OTEditor.FogColor, OTEditor.FogDensity
|
written at :2621-2622, read by nothing |
OTEditor.AmbientColor, OTEditor.AmbientIntensity
|
written at :2625-2626, read by nothing |
gameType, maxPlayers, respawnTime, timeLimit*, scoreLimit, friendlyFire
|
→ LevelMetadata → levelinfo — works
|
skyboxTexturePath |
→ LevelMetadata → levelinfo, read live by the viewport — works
|
particle* |
→ LevelMetadata → particles — works
|
Meanwhile ZoneVolumeNode::envOverrides — the per-zone fog/ambient/reverb the
game consumes (Source/Core.hpp:1662-1699, SoundManager.cpp:359-360) — is
written nowhere in the editor. Its only AngelEd occurrence is the export read
at Main.cpp:1532. So per-zone fog in a shipped level is hand-edit-only.
Also: the zone apply block never calls HistoryPush(), so its edits are invisible
to Ctrl+Z.
There is no WorldGraph tree. Two separate panels, frequently conflated:
| Panel | Control | Holds | Right-click? |
|---|---|---|---|
World Graph Explorer (ShowWorldGraph) |
WC_LISTVIEW, 6 report columns (Win32Dialogs.cpp:4118) |
world instances |
Yes — the editor's only NM_RCLICK/TrackPopupMenu (:4162) |
Pawn Manager → Actor Hierarchy (BuildPawnTree, :1555) |
real WC_TREEVIEW (:1896) |
defs to place, not instances | No — WM_NOTIFY handles NM_DBLCLK only |
That is why the Map row goes in the list: it inherits the working right-click → Properties path for free, with no panel rewrite.
- New header
AngelEd/Source/SelType.hpp.SelTypeis file-local toMain.cpp:99whileWin32Dialogs.cppcompares raw literals (selType == 5), so addingMAPas another literal is how the numbers drift. Header-only, so noMakefile/ CI inline-g++ change (headers are not listed there). - Add
MAP = 13. Safe:-1is already the "def only, no instance" sentinel set byShowDefPropertiesFor(:5796), and0is deliberately used by the non-selectable Sound/Music emitter rows.
Synthetic first entry in BuildWorldGraphEntries (:3929), before the Brush
block: typeLabel = "Map", selIndex = 0, posX/Y/Z = 0, bold via a per-row
flag on WorldGraphEntry (:3918).
Six guards — a missing branch is not harmless for an unhandled SelType:
| Hazard | Site |
|---|---|
| Pos X/Y/Z + Rot emitted before any type branch | Win32Dialogs.cpp:4896-4916 |
Delete/Duplicate added unconditionally; both dispatch chains are else if with no else, so Delete silently deselects and pushes a stray HistoryPush()
|
Main.cpp:2471-2473, :646, :688
|
Delete / Ctrl+D gate is != NONE
|
Main.cpp:4815-4823 |
DrawModel = true + SnapGizmoToSelection on every pick → draggable gizmo at {0,0,0}
|
Main.cpp:3620-3621 |
HistoryPush() runs before the dispatch → no-op Apply dirties undo |
Main.cpp:3689 |
RefreshWorldGraph() has zero callers → list never repaints after Apply |
Win32Dialogs.cpp:4217 |
All 15 LevelMetadata fields get rows, seeded from GetLevelMetadata() in a new
propsTargetType == MAP branch of ShowPropertiesPanel (:5579):
-
Game Mode — combo over
oz::gametype::AllGameTypes(); combo index ==GameTypevalue, soCB_SETCURSEL((int)meta.gameType)is an identity mapping. - Max Players, Respawn, Time Limit Enabled + Minutes, Score Limit, Friendly Fire.
-
Resolved Ruleset summary — refresh it (there is no
SetWindowTextWtoday, so the "refreshed whenever the combo changes" comment at:3172is aspirational) and derive it fromResolveGameTypeInfo, notGameTypeInfoFor, so it reflects the level's ownlevelinfonumbers and any.ozls : gametypeoverride. -
Skybox — path + Browse + Use Active Tex, seeded from state (the old dialog
used hardcoded literals
L"8"/L"5"/L"10"/L"50"). -
Weather — type combo + density/speed/RGB + Wind X/Z, which are declared
at
:2786-2787and never created, so wind is hand-edit-only today.
Four export / round-trip bugs fixed along the way:
| Bug | Site |
|---|---|
timeLimitMinutes missing from the non-default test → a lone change is dropped |
Main.cpp:1640-1643 |
particles gated on particleType != NONE → wind lost when the type is None |
Main.cpp:1657 |
gametype= tail ignored on load (arg(0) only, never pr.gametypeKey) → editor and client can disagree on a hand-edited file |
Main.cpp:1154 |
skyboxSidePath (pr.entitySubType) never read → dropped on every save |
Main.cpp:1149-1163 |
AppendOzoneEntities (:1197) is a second, colon-delimited LevelInfo/Particles
writer with no callers. Confirm dead, then delete rather than leave two writers.
SelType::ZONE section (:5072-5097) gains an Environment group — Fog
R/G/B/Density/Start/End, Ambient R/G/B/Intensity, Reverb Mix/Decay — writing
zone.envOverrides and its applyFog/applyAmbient flags, which the exporter
already honours (Main.cpp:1533).
Preview needs two genuine engine bugs fixed:
-
SurfaceMaterial.cpp:71hardcodesamb = {0.1, 0.1, 0.1, 1.0}while its comment claims the value lives onOzoneLoader— there is no accessor and it never reads it. ChangeUpdateFrameto take ambient as a parameter (2 callers:Source/Core.hpp:431,AngelEd/Source/Main.cpp:2676) rather than adding a getter, matching the existing "the owner declares it" discipline. -
SurfaceMaterial::CacheLocations()caches no fog locations, yetGameData/Shaders/Surface.fs:64-68declares and usesfogStart/fogEnd/fogDensity/fogColor/fogIntensityat:223-228. Surface-flagged brushes therefore always fog at the GLSL defaults. Add the five locations +SetFog.
Also republish ambient every editor frame from the selected zone's
envOverrides. AngelEd has zero SetWorldAmbient callers today yet calls
DrawZoneGeometry (:2793-2799), which restores the stale 0.1 and leaves it
clobbered for the rest of the frame and after.
And wire the two dead uniforms: OTEditor.FogStartLoc/FogEndLoc
(Editor.hpp:37-38) are resolved at :229-230 and never read again, while
LitFog.fs:47-51 declares both. That is exactly why the old dialog's two fog
range fields did nothing.
Approved consequence: fixing these in the client as well as the editor means surface-flagged brushes render differently in the shipped game — brighter, and actually fogged. That matches the documented intent, but expect visual re-tuning.
Delete IDM_ZONE_PROPS / IDM_LIGHT_PROPS from the menu (:1862, :1865), the
enum (:55, :58) and the dispatch (:1800, :1803).
Split ToggleEnvPanel (:1050) — it currently toggles the window and sets
g_placeMode = PlaceMode::ENV, two unrelated things. F4 (:4777) duplicates
both; F12 (:4809) calls the same helper. After removal F4 and the toolbar "Zone"
button (:3531 → :3443) keep placement mode, and F12 is unbound.
Check portal deletion first. The old Portal tab had a list + Refresh + Delete
(ID_ZONE_DEL_PORTAL → Main.cpp:4716-4723). The Entity Properties PORTAL
section (:5098-5110) has Target World / Spawn / Bidirectional but no delete.
Add it there or move the list — do not silently lose the ability to remove a
portal.
Then delete ZonePropertiesProc (:3050-3492), LightPropsProc (:3784-3909),
their registrations (:6633, :6637), creations (:6673, :6677), the
ZoneProperties struct, g_zoneProps, GetZoneProperties, ClearZoneApplyFlags,
ShowEnvPanel, the related EditorPanelState fields, and SetLevelMetadata's
17-line mirror into g_zoneProps (:2700-2716).
Keep LevelMetadata, g_levelMeta and the accessors — the Map panel owns them.
- One shared label width. The current
lw = 74(instance rows) vsdefLabelW = 96(everything from the def block on) produces a visible step in the value column at the first section header. - Bold group headers with distinct control IDs —
addSection(:4920) emits plainSS_LEFTand every header shares control ID1. - Read-only rows visually distinct. They are
ES_READONLYwith no control ID, so Apply cannot reach them; keep that, just make it look intentional. - Order: Transform →
<Type>→ Environment → Physics → Definition → Stats → Other → Actions → Apply/Close. -
StatSpecgains agroupmember; regroupkWeaponStats(currently interleaved) into Damage / Melee / Projectile / Viewmodel / Audio. - Show
StatSpec::labelas the row's primary text with the key alongside — the label is never displayed today (the panel prints the raw key at:4979). -
DefStatRow::isFloatis stored and never read → applyES_NUMBERto float rows and right-align them.
Dead code to fix:
| Item | Site |
|---|---|
ID_PP_STAT_BROWSE_0 (587-631) has no handler — the range test at :5285 covers only ID_PP_STAT_PREVIEW_0, so Browse on a sound stat row is unreachable dead code |
:5285 |
No-op idx computation (both arms yield 0) |
:4994-4996 |
"read-only: def is packaged" branch is unreachable, because FillDefBlock only fills propDefEditable when propDefWritable — so a packaged def drops the whole section instead of showing it read-only |
:4969-4977, :5519
|
ID_BTN_GT_PREVIEW — declared and created, no WM_COMMAND branch anywhere |
removed with P3 |
AngelEd-only (P1, P3, P4) — nothing under Source/ changes.
Shared engine — four sites, one phase (P2):
| File | Change | Callers |
|---|---|---|
Source/Renderer/SurfaceMaterial.hpp |
UpdateFrame also takes ambient |
2 |
Source/Renderer/SurfaceMaterial.cpp |
drop the hardcoded ambient; add fog locations + SetFog
|
— |
Source/Core.hpp:431 |
pass the world's ambient | client |
AngelEd/Source/Main.cpp:2676 |
pass the editor's ambient | editor |
No Makefile change (SurfaceMaterial.cpp is already in all three build lists),
no CI inline-list change, no protocol, no .ozls writer, no save format.
Out of bounds without asking: network protocol, Makefile object lists,
.ozls writer semantics, save-file formats, Source/Pawn/, Source/Script/,
Source/Package/. If a bug forces a fix outside that list, stop and ask.
| Invariant | Why | Where |
|---|---|---|
addReadOnlyRow creates EDITs with no control ID
|
Apply must never reach read-only rows | Win32Dialogs.cpp:4926 |
| Per-row control IDs, not shared Preview/Browse + hit-test | a click cannot land on the wrong weapon's sound |
:5542, :5281
|
PropsUpdateScroll runs after the resize |
nPage comes from the client height; reversing it leaves a panel taller than the work area with no scrollbar |
:4776-4780 |
propsPanelPos.w = 540 |
PropsFitWindow re-applies the create-time width; shrinking it steals the value column |
Win32Dialogs.hpp:374 |
LevelMetadata / g_levelMeta / accessors survive P3 |
the Map panel owns them | Win32Dialogs.cpp:2694-2717 |
envOverrides export gate applyFog || applyAmbient || reverbMix > 0
|
an explicitly-zeroed reverb is intentionally not emitted | Main.cpp:1533 |
| Children move to scroll; owned painting scrolls at paint time | two different mechanisms, do not mix |
:4781 vs :944
|
| A def gets an editable stat row only if a runtime consumer exists | see below |
PropsPanel.cpp StatSchemaFor
|
An editable row is a promise: the author types a number and the game behaves differently. A row for a key nothing reads is worse than no row, because it silently lies about what a level does. The rule, applied throughout the game-hierarchy schemas:
- A key is editable only when a shipped code path reads it at runtime — and the schema row cites that consumer in a comment.
- A key with no reader is not offered editable. It still appears in the read-only dump (so nothing hand-authored is hidden), and the def's type gets a note explaining why there is no editor.
- A dead schema entry is a bug. If the consumer is removed, the row goes with it in the same commit.
StatSchemaFor() returns nullptr for EntityType::TRIGGER and
EntityType::EVENT on purpose: TriggerSystem reads a trigger's box and def
name from the OZONE line, not from the def; and event topics are emitted by
the engine (world.load, entity.*, pickup.collect, zone.enter|exit, ...)
and consumed by the LightningEntityManager wildcard bridge. Neither def type
owns a runtime stat today. FillDefBlock therefore sets propDefNote for them
— "No runtime-consumed stats … read-only template" — rendered under the
Definition header, and the row dump stands on its own.
Do not "fill in" TRIGGER/EVENT schemas to make the panel look complete. Do it when a runtime consumer lands: add the reader, the schema row, and the note removal together.
- No WorldGraph tree exists. See the table above.
-
Light Properties already forwards when a light is selected. Its Apply only
misbehaves in the no-selection case, because of the
-1target. - Zone Properties' Fog/Ambient tabs are level-global, not per-zone, despite the name.
-
Unreachable / dead, not undiscovered features:
ID_BTN_GT_PREVIEW,ID_SF_PAR_WINDX/ID_SF_PAR_WINDZ,RefreshWorldGraph(),AppendOzoneEntities(). -
Sound/Music emitter rows carry
selType = 0deliberately, soOpenPropertiesForSelectionbails atMain.cpp:792. -
g_editorPanels.propsTargetType = -1is the Script Manager's def-only sentinel, so out-of-range values are already valid.
| Phase | Item | Status |
|---|---|---|
| P0 |
Wiki/Editor-PropertyPanel-Refactor.md + AGENTS.md pointer |
done |
| P0 |
AngelEd/Source/SelType.hpp — SelType moved out of Main.cpp:99, MAP = 13 added, 24 raw literals in Win32Dialogs.cpp replaced with sel::*
|
done |
| P1 | Map row + 6 guards + level rows + apply + 4 export fixes | done (all 5 targets build) |
| P2 | zone env authoring + preview + 2 SurfaceMaterial fixes + dead uniforms |
done |
| P3 | remove both windows, split ToggleEnvPanel, keep portal delete |
done |
| P4 | re-layout + 4 dead-control fixes | done |
| V1–V6 | build, test, round-trips, regression sweep | pending |
-
The Map row is NOT bold. A ListView has no per-item bold state
(
LVIS_BOLDdoes not exist) andNM_CUSTOMDRAWcannot supply one either: MinGW'sNMLVCUSTOMDRAWexposes onlyiSubItem(the column), notiItem, so there is no portable row index to test. It is distinguished by the label"Map (level)"plus blank position columns instead. -
LevelMetadata::skyboxSidePathadded. It did not exist — the second OZONElevelinfopath token was parsed intoOzonePrimitive::entitySubTypeand then dropped by both the loader and the exporter. -
ApplyMapProperties()is a separate function with its own diff-based undo policy, dispatched before the genericHistoryPush(). -
ChooseColorRGB()added (CHOOSECOLORW). The legacy dialog used three HSCROLL sliders per colour, which cannot express an exact value. The new Environment/Weather rows are text fields with a picker. - Two additions beyond the listed items, both load-bearing:
Win32Dialogs.cppgained asel::MAPbranch in the WorldGraph popup menu (it had its own Delete/Duplicate, same silent-no-op problem), and the world name for the Map row comes fromEditor_GetCurrentWorldDir().
-
OzoneLoadernow owns the world fog too (SetWorldFog/GetWorldFog, alongside the existingSetWorldAmbient). The plan said to pass ambient as a parameter and to add fog locations only toSurfaceMaterial, which left the question unanswered: where does the surface program get fog?Core.hppsets the five fog uniforms onLitFogfrom three separate sites (startup defaults, zone entry, zone exit). Mirroring at each would be three places to keep in step; publishing once intoOzoneLoadermakesUpdateLightSources()the single mirror point. Same reasoning as theSetWorldAmbientcomment that already existed for ambient. -
The editor preview no longer reads the legacy global. It reads the
selected zone's
envOverrides(Main.cpp, per-frame block). That is whatCore.hpp/ZoneManagerapply at runtime and whatExportToOzonewrites, so previewing the legacy global showed the author a result the game never produces. As a side effect the block no longer depends on the old window's one-shotapply*flags, which is what makes the old window deletable rather than something that has to be kept in sync. Only the level-state half (GameType/ skybox / particles) still reads those flags, and it dies with P3. -
FogStartLoc/FogEndLocare not "resolved and never read" — they are resolved atEditor.hpp:229-230, written once at:242-243, and then never updated. The precise bug is that the legacy dialog's Start/End rows looked live and did nothing: colour, density and intensity tracked the zone while the fog distance stayed pinned at 10/100. -
ZonePropertiesfield removal deferred to P3. The struct still carriesfogStart/fogEnd/reverbMix/reverbDecayfor the old dialog's benefit; they become dead the moment that window goes.
-
Portal deletion was NOT left to the panel's Apply. The plan said "add it
there or move the list". It is a button (
ID_PP_PORTALDELETE = 470) that setsactionDeletePortal = propsTargetIndex, because delete mutates the vector the panel is indexing into — routing it through the ordinary Apply path would apply the panel's stale size fields to the portal that shifts down into that slot. -
Deleting a portal now clears the selection.
RemovePortal()shifts every later portal down by one, so a retainedpropsTargetIndexmeant the next Apply silently edited a different portal. The old list hid this by rebuilding itself. -
The dead portal-apply plumbing went too, not just the windows.
PortalEditState,PortalEditValues,GetPortalEditValues,SetPortalSelection,LoadPortalIntoEditor,actionApplyPortalandactionSelectPortalwere all only reachable from the removed window. Portal editing is the panel'stgtType == sel::PORTALApply branch; picking a portal in the viewport now opens the panel instead of callingSetPortalSelection(). -
The level-state half of the per-frame block was deleted, not migrated. It
existed only to notice the old window's one-shot
applyGameType/applySkybox/applyParticlesflags. With the window gone there is no setter, so keeping the reader would have been an unreachable branch. -
RefreshPortalList()is now an intentional no-op, kept because three Main.cpp call sites (portal create/delete) use it as the "portals changed" notification. Its old body was already dead behind anhEnvPanelguard. -
ID_PP_PORTALBROWSE = 470was already dead — declared, never created, never handled. Its slot was reused forID_PP_PORTALDELETE. -
GetPortalCount()/GetPortalTargetWorld()had zero callers and were deleted in the b88 dead-code sweep (theirUiPanels.hppdeclarations, inline fallbacks andUI/Panels/LevelState.cppdefinitions all went together).
| Item | Why it is dead |
|---|---|
GetPortalCount(), GetPortalTargetWorld()
|
deleted — zero callers, predated this work |
ID_PP_PORTALBROWSE |
removed in P3 (slot reused) |
ID_BTN_GT_PREVIEW |
declared and created in the removed window, so now gone |
Editor.hpp ShowEnvPanel flag |
nothing reads it; the window is gone |
-
StatSpec::labelis now shown, with the key alongside it —Label (key)in a 190px column. The def block (read-only dump, PawnDefs rows and schema rows) shares that width, so the value column steps once, at theDefinitionheader, rather than mid-section. A tooltip was considered and rejected: it needs a tracking tooltip control and buys nothing over showing both strings. -
Float rows get
ES_RIGHTat creation, notEM_SETALIGN.EM_SETALIGNis not declared by MinGW's headers, and the style bit is what the message sets. Right-alignment is a hint —ES_NUMBERstays off because it rejects both a leading-and avec3like(0.1, 0.2, 0.3). -
StatSpec::grouppopulated for all four schemas (82 rows) andkWeaponStatsreordered so each group's rows are contiguous —swing_speedandreachused to sit between the Handling rows. Groups: weapon Damage/Handling/Melee/Projectile/ Viewmodel/Audio, player Vitals/Progression/Audio, pawn Combat/Appearance/ Animation/Audio, light Effect/Shadow/Spot cone. -
StatSpec::grouphas a default-ish empty value, meaning "continue the previous group".DefStatRow::labelfalls back to the key when a schema omits one, so a stat with no label still renders. -
Section headers are bold via
WM_SETFONTwith a single sharedGetBoldUiFont()HFONT. A STATIC control has no bold style bit, so there was no other way; the font is deliberately leaked because live controls must not outlive their HFONT. Header IDs are nowID_PP_SECTION_0 + n(parked at 20000) with a counter reset per rebuild — they used to all share control ID1.
| Check | Result |
|---|---|
make MODE=debug OTENGINE AngelServ AngelMaster ozpack |
clean |
make -C AngelEd MODE=debug |
clean |
make test (13 suites) |
all green — 7/7, 39/39, 22/22, 26/26, 15/15, 11/11, 20/20, 26/26, 4/4, 114/114, 54/54, 21/21, 0 failed |
make worldcheck (6 shipped worlds) |
0 errors, 38 warnings (pre-existing) |
Everything below is interactive and cannot be covered by the headless suites:
AngelEd is Windows-only and not in the test link, and the SurfaceMaterial change
alters pixels.
-
Map round-trip: load
TestMap, right-click the Map row, edit Game Mode / Skybox / Weather, Apply, save, reopen → values intact and thelevelinfo/particleslines are correct. - Zone env: select a zone, set fog/ambient/reverb → the viewport preview changes, surface-flagged brushes follow (not stuck at 0.1), and the exported zone line carries the values.
- Menu: View has neither entry; F4 and the toolbar Zone button still enter zone placement; a portal can still be deleted; deleting the selected portal closes the panel rather than leaving it aimed at a shifted index.
- Stats: change a float stat (right-aligned); Preview and Browse both work on a sound row — Browse was dead until P4.
-
Packaged def: select a weapon that resolves from a
.oz*package → the stats section now appears (read-only, no control IDs) instead of vanishing, and Apply does not strip it. -
Regression: undo/redo across a MAP Apply, a zone env Apply and a stat
patch; plus a client visual check of the
SurfaceMaterialchange in both a lit room and a fog volume — not just that it compiles.
Note:
GameData/Worlds/TestMap/World.ozonewas already dirty in the working tree before this work began (an earlier AngelEd re-export that droppedflags=16). Do not revert it; only change it as far as these edits go.
Angels95 - AngelTech Engine | repository | 8ea8239
Built on raylib 5.5.