World-overview viewer
The reverse-engineering provenance behind the /world-overview/ static-site WebGL viewer. Engine-runtime semantics (top-view toggle, controller, render pipeline, bulk-terrain emit mechanism) live on the world-map subsystem page — this page covers how the viewer reconstructs that runtime from disc bytes alone (no emulator, no save state).
Why a separate page
The world-map subsystem documents what the retail engine does at runtime. The viewer faces a different problem: assemble the same scene end-to-end without running the engine. That requires (a) layout heuristics for slot-1 TMDs the MAN table doesn't pin, (b) a WebGL-shader approximation of the runtime's distance-cue fog pass, (c) a Rust-side resolver for actor placements that retail only computes via the field VM, (d) disc-side extraction of the ocean tile + 13-frame CLUT animation, and (e) hand-tuned camera anchors that match the retail dev-menu top-view. Each item below documents a piece of that reconstruction.
Layout engine for unplaced slot-1 TMDs
The MAN placement table pins a small subset of each kingdom's slot-1 TMD pack at world coordinates (5 / 6 / 17 slots for Drake / Sebucus / Karisto). The remaining slots are positioned at runtime by the field VM via actor-mesh chains and don't carry a static world coord. The viewer's show unplaced slot-1 TMDs toggle drops those onto a canonical layout grid, classified by site/world-overview/slot1_classification.toml:
- landmark — row south of the kingdom bounds, sorted by slot.
- decoration — row north of the kingdom bounds.
- ground_tile — grid west of the kingdom (the runtime tiles them via the overlay-routed dispatch table; see the bulk-terrain emit mechanism).
- npc_token — hidden (reused generic actor bases; the count is reported in the status line).
- unknown — grid east of the kingdom.
Two per-mesh transforms keep the layout legible:
- AABB-centroid anchor — each unplaced TMD is drawn so its AABB centroid sits at the assigned grid slot, instead of its TMD-local origin (which can be far from the visual centre and shift the mesh out of frame).
- Class-conditional footprint normalisation — per-class target footprints in world units (landmark ~600, decoration ~200, ground_tile ~1200, unknown ~600). Each mesh's larger XZ extent maps to the target via a per-placement scale so the row reads at a consistent size regardless of the TMD's native scale.
The normalize unplaced toggle disables both transforms (falls back to the legacy constant scale + TMD-local-origin pivot) so the user can ground-truth against retail.
Distance-cue fog pass
The viewer's fog toggle approximates the retail world-map fog: the diffuse term fades toward a per-kingdom haze colour with distance. The math splits into two pieces the runtime keeps separate, and the WebGL port mirrors that split:
- The LUT at
gp-0x2BC(2048 u16 entries that climb from0x0000at near-Z to~0x01FFat far-Z) is a per-Z scalar, not a colour ramp. The retail overlay leaves at0x801F7644..0x801F8690lhthe LUT entry, shift it left by 16, and add it to the high half of vertex SXY+offset words viasw s1, 0x8(t1)/0xC(t1)/0x10(t1). The visible effect on flat triangles is a per-vertex screen-Y nudge proportional toZ >> 5. - The haze colour is set per-kingdom via the GTE
FAR_COLORcontrol register (loaded viactc2during world-map enter, not surfaced by thelwc2 t0, -0x2dc(t2)load — that field is theIR0depth-cue factor, despite earlier doc tables labelling it “fog color”).
The WebGL port runs this in a vertex + fragment shader:
- Per-vertex:
Z_far = exp2(-zShift) * dist(world, camera_origin), clamped to[0, far_ref]and normalised tov_fog_t in [0..1]. Approximates the runtime'sZ_far = Z >> shiftagainst the top-down camera origin. - Per-fragment: sample
lut[clamp(v_fog_t * 2047, 0, 2047)]as a scalar u16; normalise tofactor = lut_word / 511; thenmix(lit, u_fog_color, factor)withu_fog_color= the per-kingdom haze tint fromKINGDOM_FOG_TINT. This produces the fade-toward-haze visual instead of treating the LUT entries as RGB tints (an earlier port did the latter and produced “richer textures” rather than fog).
The shader supports two LUT sources, in priority order:
- Disc-extracted LUT (default) — the WASM viewer locates the 4 KiB (2048 u16) LUT inside
SCUS_942.54via thefog_lut::findcontent-scan (monotone non-decreasing ramp with leading zero entries + saturating tail) and auto-uploads it on disc load. No file picker; one disc upload = full functionality. On the retail USA build the LUT sits at SCUS offset0x05FCC0(vaddr0x8006FCC0); the content scan handles regional variants without hardcoding. - Kingdom-tinted fallback — when SCUS extraction doesn't surface a LUT (raw PROT.DAT load, regional variant with shifted SCUS, modded disc), the shader falls back to using
v_fog_tdirectly as the mix factor, still toward the kingdom haze tint.
The per-vertex math diverges from retail in one place: retail samples Z from the GTE's screen-space pipeline after rtpt, while the WebGL2 path uses XZ-plane distance to the fog origin (fog_origin = worldCam centre by default). For a top-down ortho camera the two quantities are equivalent up to a constant; for the orbit-camera mesh inspector the fog toggle is hidden because it doesn't carry over.
Bulk-terrain placement resolver (MAN 0x7F sentinels)
MAN-record placements where (x_enc, z_enc) == (0x7F, 0x7F) static-decode to the literal world coordinate (16320, 16320) (the world's NE corner, outside any visible kingdom). Those actors are positioned at runtime by the FieldVM prescript embedded in the record's trailing bytes, dispatched from FUN_8003A1E4 (the MAN placement walker in SCUS): if script[PC] is opcode 0x24 or 0x25 the walker enters a FUN_801DE840 loop that writes actor[+0x14] / actor[+0x18] (X / Z position) from per-record state. The resolved position therefore differs from the literal MAN-record decode.
Static resolution without running the FieldVM is not covered by the asset extractor — the prescript is a per-record bytecode that branches on actor type, story-flag state, and overlay-resident lookup tables. The practical alternative is a runtime snapshot capture:
scripts/mednafen/resolve_bulk_terrain.pyextracts the post-resolve placements out of mednafen save states. It walks every actor list head, captures each actor's live+0x14 / +0x18coords plus its mesh chain at+0x44(resolved back to the kingdom TMD pack via reverse-magic-search), and tags each placementkind: 'bulk_terrain'whenactor[+0x90]is outside the MAN buffer or'man_actor'otherwise.scripts/extract-world-placements.pymerges the result intosite/world-overview.jsonunderbulk_terrain_placementsper kingdom (alongside the existingplacementsandlive_placementsfields). The world-overview viewer renders both layers in the same scene; the regression digest hashes onlyplacementsso live captures don't perturb it.crates/web-viewer::sentinel_placementsis the Rust port of the RAM-side resolver (record parser, actor-list walker, TMD-pack reverse lookup) for downstream callers; the Python script is the end-to-end driver.
Per-kingdom fog colour
The atmospheric-tick actor (actor[+0x0C] == FUN_801E3E00 at 0x801E3E00) interpolates the per-kingdom haze RGB into its +0x74 field per frame. That u32 is the input to FUN_80043390's three ctc2 writers to the GTE FAR_COLOR control regs ($21 / $22 / $23): FUN_8001ADA4 case 5 calls FUN_80043390(prim_ptr, actor[+0x74], actor[+0x78]); inside the dispatcher each RGB byte is extracted with andi/srl, shifted left by 4 to scale 8-bit → 12-bit, and ctc2'd to the GTE control register.
The script that drives actor[+0x74] lives in FUN_801E3E00 and reads its R/G/B bytes from script[PC + 7/+8/+9]. The script source is a per-kingdom blob at actor[+0x94]. When scripts/mednafen/resolve_bulk_terrain.py finds an actor with tick == 0x801E3E00 and actor[+0x74] != 0, it surfaces the live RGB as fog_color per kingdom in site/world-overview.json. The world-overview viewer reads that field at priority above the hand-eyeballed KINGDOM_FOG_TINT fallback; world-map saves that don't have an active atmospheric tick fall back to the hardcoded table.
Ocean tile — disc-side asset + 13-frame CLUT animation
The world-map ocean is a static 4bpp tile + CLUT cycling animation, both shipped on disc:
- Texture: PSX TIM image at VRAM
(768, 256)64 halfwords × 256 rows (= 256 × 256 logical pixels in 4bpp), inside slot 0 (TIM_LIST) of each world-map kingdom bundle (PROT 0085 Drake / 0244 Sebucus / 0391 Karisto). The kingdom-specific TIM is the one with CLUT block fb_xy(0, 506)and image block fb_xy(768, 256). Texture bytes vary per kingdom (each ships its own variant). - Wave-ramp region: ocean data fills the top-left 96 × 96 logical pixels of the 256 × 256 page; the rest is shared with other tile prims in 4bpp mode and reads as CLUT-entry-0 padding at world-map entry. Confirmed by walking non-zero byte density across every row and byte column of the decompressed image; the prim-trace POLY_FT4 cluster UVs for the
clut=0x7E80 tpage=0x001Cfamily land entirely inside this envelope (UVs from(0,0)to(95,95)). - Base CLUT: 256-entry BGR555 row at VRAM
(0, 506)(same TIM as the texture). The first 16 entries are the ones the runtime overwrites per frame; entries 16..255 stay fixed and belong to other tiles sharing the row. - Animation table: 13 frames × 16 BGR555 entries = 416 bytes, byte-identical across all three retail kingdoms (SHA-256
dfc6dd263a71152c40ab7726193d79e9658efc04402f4280f5f49f392e32071f). Located by signature scan in each kingdom's decompressed slot 0; the disc wraps each frame in a 532-byte "CLUT-only TIM" record at TIM_LIST slots 3-5 (Sebucus/Karisto) or 10-15 (Drake), with the first frame starting0x54bytes into the record.
The runtime DMAs one frame at a time onto VRAM (0, 506), overwriting the first 16 CLUT entries; the wave peak (0x3D05 bright blue) propagates through indices 0..7 over the 13-frame cycle, creating the horizontal rolling-wave appearance visible in retail.
crates/web-viewer::ocean::find_ocean_assets decompresses the kingdom bundle's slot 0, locates the ocean TIM by VRAM coords, and signature-scans the slot for the animation table. The disc-gated test crates/web-viewer/tests/ocean_assets.rs verifies extraction across all three kingdoms. The WebGL ocean shader (site/js/webgl-tmd.js) samples the 4bpp texture + animated 16-entry CLUT, advancing the frame counter on a wall-clock timer. Before the disc is loaded the renderer falls back to a solid ocean tint sourced from world-overview.json[kingdom].ocean_color (CLUT-sampled from save state via scripts/mednafen/resolve_bulk_terrain.py::pick_ocean_color).
Camera anchors
Per-kingdom camera centres + zoom anchors live in two tables and a JSON override:
KINGDOM_CAM— walk-view spawn anchors (load-time map-origin coords from_DAT_80089118/_DAT_80089120, decoded bymednafen-state world-map-camera --table <save>). Default view when a kingdom tab is opened.KINGDOM_TOPVIEW_CAM— hardcoded fallback for the lock to retail top-view button.world-overview.json[kingdom].topview_cam— per-kingdom capture preferred overKINGDOM_TOPVIEW_CAMwhen present.resolve_bulk_terrain.py::capture_topview_camwrites this frommednafen-state world-map-cameraagainst the user-supplied save state for each kingdom.
The captured anchor is the load-time map origin (-_DAT_80089118 / -_DAT_80089120). Top-view dev-menu captures (DAT_801F2B94 != 0) would refine this with an interactively-scrolled centre + a refined zoom; walk-view captures match the spawn anchor, which is good enough as a "lock" target since the dev-menu top-view also enters from this anchor before user input scrolls it.
Static placement data — how it was captured
The 2D scatter and assembled-scene actor lists are baked into site/world-overview.json at build time, parsed from each kingdom's MAN asset and live-RAM actor pool. Drake additionally surfaces 12 live-RAM actor placements (gates, bridge towers, the small castle) via scripts/pcsx-redux/resolve_actor_tmds.py: each world-map actor's mesh_head[+0x44] chain is walked to find the containing TMD via backward magic-word search, then mapped to a slot index. The result is baked into site/world-overview-live.json; the viewer overlays those placements on top of the MAN entities.
Top-view capture is performed against mednafen save states — the same view that FUN_801E76D4 enters when the debug toggle fires. Steps the extraction performs against each save state's main RAM:
- Read the active CDNAME label as ASCII at
0x80084548and confirm it matches the expected kingdom (map01/map02/map03). - Walk the seven linked-list heads at
0x8007C34C..0x8007C36C: each list starts at*head_ptrand chains via the actor's+0x00next-pointer. - For each actor, read
(X, Y, Z)as threei16at+0x14,flagsat+0x10, and the actor's id byte at+0x50. - Filter out zero-position entries (system actors that aren't placed in world-space).