World-overview viewer
The world-overview page lets you fly a camera over each of Legaia's three kingdom overworlds, assembled in your browser from a disc image you supply: terrain, landmarks, animated ocean, retail fog. This page is how the viewer rebuilds what the running game shows from disc bytes alone, with no emulator and no save state. What the retail engine does at runtime - the controller, the top-view toggle, the terrain emit mechanism - is on world map.
At a glance
- Input
- A disc image in the browser tab. Save-state captures only refine camera anchors, the fog colour and the script-placed actors
- Kingdom bundles
- PROT 0086 Drake / 0245 Sebucus / 0392 Karisto; the walk
.MAPis the entry two slots before each block (a fixed0x12000-byte footprint) - Ocean
- A static 4bpp tile at VRAM
(768, 256)whose first 16 palette entries at row(0, 506)are rewritten from a 13-frame table - Fog
- Retail's 2048-entry per-Z scalar LUT, found by content scan in
SCUS_942.54, mixed toward a per-kingdom haze colour - Code
legaia_web_viewer::{build_walk_ground, build_walk_placements, ocean, sentinel_placements, scene_export};site/js/world-overview-app.js,webgl-tmd.js,webgl-math.js- Confidence
- Heightfield, stamps, ocean and fog LUT: Confirmed against the engine and save-state VRAM. Unplaced-mesh layout and global-pool placeholders are viewer conventions, not retail
- Used by
- World overview · VR mode · Asset viewer (full-map mode)
What the viewer has to rebuild
Retail leaves a lot to runtime code. Each kingdom's bundle ships a pack of landmark meshes (the "slot-1 TMDs"; TMD is the game's mesh format) that the scene's MAN - its script-and-placement container - only partly positions; the rest are placed by the field VM, the game's script interpreter. Five pieces close that gap:
| Piece | Source | Section |
|---|---|---|
| Continent ground | Walk .MAP floor grid, generated the way the game does | heightfield |
| Landmarks and decorations | Walk-map per-cell records | stamps |
| Fog | SCUS LUT + per-kingdom haze colour | fog, colour |
| Ocean | Kingdom bundle slot 0 tile + 13-frame CLUT table | ocean |
| Script-placed actors | Save-state captures (the disc cannot say where they rest) | sentinels, anchors |
Continent ground heightfield
The green-and-brown ground is not a mesh on the disc. It is generated from the walkability map's floor heights, one cell at a time, exactly as the game builds it on entry to the overworld: a heightfield in the col × 128 world frame, textured per cell from a terrain-type-keyed atlas (grass, mountain, water, forest). The floor-height LUT is 16 signed halfwords at the start of the kingdom's MAN; the surface math is the engine's bilinear floor sampler, shared with the native world-map render.
Code: legaia_asset::field_objects::build_walk_heightfield, wrapped for the browser by legaia_web_viewer::build_walk_ground. The model and per-cell texturing are pinned on world map.
Landmarks and decorations
Towers, castles and bridges are stamped onto specific tiles; forests and mountain ranges are the same few small meshes stamped dozens of times. Both layers come straight from the walk map's per-cell records and draw in the same world frame as the ground.
| Layer | Record rule | Count (Drake / Sebucus / Karisto) |
|---|---|---|
| Placed landmarks | Objects with the placed flag 0x4, stamped on occupied tiles | 5 / 6 / 17 |
| Decorations | Walk-visible cells with a nonzero mesh slot at +0x10, the mesh-drawn bit 0x2 and no placed flag | ~295 / ~240 / ~210 |
| Not decorations | Nonzero mesh slot without the 0x2 bit (the riverbank record family, identical across kingdoms) - stamping them tiles a wall down every river | - |
- Height. World Y =
−lut[floor_nibble] + y_off, so each stamp sits on the heightfield. The JS negates that anchor before handing it to the model builder: the ground bakes−lutinto its vertices and lands at+lutunder the shared(1, −1, 1)flip, so an un-negated anchor buries a stamp by2 × (lut − y_off)- whole cave entrances vanish on Drake's mountain cells. - Yaw. Each record carries an authored yaw (4096 per turn) that retail copies into the actor's rotation triple; the Sebucus island bridges' quarter turns and the trees' variety come from it. Retail's pure-Y matrix maps local
+Zto(sin, 0, cos), the opposite sense of the JS helper, hencerotY = −(rot & 0xFFF) · π / 2048. - Cutout. The PSX rule (BGR555 zero with STP clear discards) makes the crossed-quad tree billboards read as foliage; the kingdom's real VRAM image is uploaded and CLUTs resolve like retail.
- One suppressed stamp. Placed objects are script-managed - retail gates each spawn on a MAN interaction record and runs its leading ops inline, so a record's resting position is not always its grid cell. Drake's record-349 golden bridge sits over the river in the grid but retail shows the one bridge at the record-441 road crossing;
WALK_STAMP_SUPPRESSdrops it by(mesh, x, z).
Code: legaia_web_viewer::build_walk_placements over parse_placements + parse_walk_decorations. The disc-gated walk_placements_parity.rs asserts the viewer's placements equal the engine's for all three kingdoms; window.__woWalkStamps exposes the queued stamp list for headless checks.
Distance-cue fog
Far-off terrain fades into a coloured haze. Retail keeps two things separate and the shader mirrors the split: a per-Z scalar LUT (2048 halfwords climbing from 0 at near-Z to ~0x1FF at far-Z) and a haze colour loaded per kingdom into the GTE's far-colour registers.
| Stage | Retail | WebGL |
|---|---|---|
| Depth term | Z >> shift from the GTE screen-space pipeline | exp2(−zShift) × dist(world, camera), clamped and normalised to [0, 1] |
| Scalar | lut[z], applied by the overlay as a per-vertex nudge | lut[t × 2047] / 511 |
| Colour | Far colour via ctc2 at world-map entry | mix(lit, haze, factor) |
| LUT source | SCUS, offset 0x05FCC0 on the USA build | fog_lut::find content scan (monotone ramp, leading zeros, saturating tail) - regional variants need no hardcoding; without a hit the mix factor is the depth term itself |
For a top-down camera the two depth terms agree up to a constant; the orbit-camera mesh inspector hides the fog toggle because they do not.
History: two misreadings of the fog registers
The lwc2 load beside the LUT is the IR0 depth-cue factor, not the fog colour - the colour arrives through ctc2. And the LUT entries are per-Z scalars, not RGB tints; treating them as tints produces "richer textures", not fog.
Per-kingdom fog colour
The haze colour is not a constant in the executable. An "atmosphere" actor runs a small per-kingdom script every frame that interpolates the haze RGB into the actor's colour word; the primitive dispatcher then splits that word into bytes, scales 8-bit to 12-bit and writes the three GTE far-colour registers. The capture script surfaces the live RGB of any actor running that tick as fog_color in site/world-overview.json; the viewer prefers it over the hand-tuned KINGDOM_FOG_TINT fallback.
Ocean tile and its 13-frame palette animation
The water texture never changes - its palette does. Sixteen colours cycle through thirteen frames and a bright peak walks across them, so the same pixels read as rolling waves.
| Asset | Where | Detail |
|---|---|---|
| Texture | Kingdom bundle slot 0 (TIM list), image at VRAM (768, 256) | 4bpp, 256 × 256 logical pixels; the wave ramp fills the top-left 96 × 96, the rest is shared page padding. Bytes differ per kingdom |
| Base CLUT | Same TIM, row (0, 506) | 256 BGR555 entries; only the first 16 are animated, the rest belong to other tiles on the row |
| Animation table | Slot 0, CLUT-only TIM records (slots 3-5 Sebucus / Karisto, 10-15 Drake), first frame 0x54 bytes in | 13 frames × 16 entries = 416 bytes, byte-identical across the three kingdoms; the peak 0x3D05 walks indices 0..7 |
| Cadence | Kingdom bundle slot-5 CLUT-walk table | A 16 × 1 copy onto (0, 506) every 9 vsyncs; the viewer uses 0.1 s per frame on a wall clock |
The stepping table is the slot-5 CLUT-walk table the live engine steps directly - not the script-driven CLUT-cell family, which only carries the row-498 park one-shots. Code: legaia_web_viewer::ocean::find_ocean_assets (disc-gated test ocean_assets.rs across all three kingdoms). Before a disc is loaded the sea is a solid tint from ocean_color, CLUT-sampled out of a save state.
Shader and camera
- One palette write per step. Each animation step writes the frame's 16 entries into the VRAM texture at
(0, 506)- the row every water cell of the heightfield samples - so open sea and terrain-embedded water shimmer phase-locked as one layer. It advances even with the backdrop pass off. - Backdrop. A flat quad at
y = 0extending past the continent, drawn first so the depth test occludes it; the "ocean" checkbox toggles only this pass. - Perspective orbit. The retail world map is genuinely 3D (GTE perspective, confirmed by moving the camera with GameShark).
buildWorldOrbitVporbits a ground-plane target on yaw and pitch; zoom keeps its orthographic meaning so wheel and pan behave the same at any tilt. Left-drag pans, right- or shift-drag orbits, "lock to retail top-view" flattens the pitch on the captured anchor. Both camera paths keep the retail screen basis: the map rotated 180° and mirrored horizontally.
Script-placed actors
Some MAN placement records carry no real coordinate: the encoded pair (0x7F, 0x7F) decodes to the world's north-east corner and means "a script will put me somewhere". Retail's placement walker sees a leading op 0x24 / 0x25 in the record's trailing bytes and enters a field-VM loop that writes the actor's position from per-record state - bytecode that branches on actor type, story flags and overlay tables, so it is not resolved statically.
| Tool | Role |
|---|---|
scripts/mednafen/resolve_bulk_terrain.py | Walks every actor list in a save state, captures live positions and the mesh chain, resolves the mesh back to the kingdom pack by reverse magic search; tags bulk_terrain vs man_actor |
scripts/asset-investigation/extract-world-placements.py | Merges the result into site/world-overview.json as bulk_terrain_placements; the regression digest hashes only the static placements |
crates/web-viewer::sentinel_placements | Rust port of the RAM-side resolver for downstream callers |
scripts/pcsx-redux/resolve_actor_tmds.py | Drake's 12 live actor placements (gates, bridge towers, the small castle) into site/world-overview-live.json |
Global-pool placeholders. Placements with tmd_slot ≥ 0xF0 reference the global mesh pool rather than the kingdom pack. That pool is not bundled, so the viewer stamps the pack's slot-0 mesh at the decoded coordinate, tags it global_pool, and reports both the reference count and the placeholder count so the gap stays visible.
Unplaced slot-1 meshes. Each kingdom ships more landmark meshes than its table positions. The "show unplaced" toggle lays the leftovers out beside the continent by class (slot1_classification.toml): landmarks in a row to the south, decorations to the north, ground tiles in a grid to the west, unknowns to the east, NPC tokens hidden. Each mesh is anchored on its bounding-box centroid and scaled to a per-class footprint so the rows read at one size; "normalize unplaced" turns both transforms off for ground-truthing.
Camera anchors
| Table | Source | Used for |
|---|---|---|
KINGDOM_CAM | Load-time map-origin globals, decoded by mednafen-state world-map-camera | Default view when a kingdom tab opens |
world-overview.json[kingdom].topview_cam | Per-kingdom capture written by resolve_bulk_terrain.py | "Lock to retail top-view", preferred |
KINGDOM_TOPVIEW_CAM | Hand-tuned | Fallback for the lock |
The captured anchor is the spawn origin the dev-menu top-view also enters from before any scrolling, which is why a walk-view capture is a good enough lock target.
Details: how the static placement lists are captured
- Read the active CDNAME label at
0x80084548and confirm the kingdom (map01/map02/map03). - Walk the seven actor-list heads at
0x8007C34C..0x8007C36C, chaining on each actor's+0x00next pointer. - Read position as three
i16at+0x14, flags at+0x10, the id byte at+0x50. - Drop zero-position entries (system actors not placed in world space).
Continent .glb export
"Download .glb" bakes the assembled view - heightfield plus every stamp on screen - into one binary glTF through the shared scene_export session (bake in legaia_asset::scene_gltf). The exporter receives the same mesh buffers and (translation, rotY, scale) triples the page renders with, so the file matches the screen. The PSX VRAM + CLUT indirection is baked into one RGBA atlas, a 256 × 256 tile per distinct (cba, page) pair, nearest-sampled, with alpha for the cutout rule. The ocean is a screen effect and is excluded. The asset viewer's town export rides the same session; the monster export is the sibling monster_gltf, which also carries animations.
How we know
| Function | Address | What it proves | Dump / probe |
|---|---|---|---|
| Floor sampler | FUN_80019278 | Bilinear floor height from the walk grid - the heightfield surface | ghidra/scripts/funcs/80019278.txt |
| Placement stamper | FUN_8003A55C | Placed-flag objects, yaw copy into the rotation triple, MAN-gated spawns | 8003a55c.txt |
| MAN placement walker | FUN_8003A1E4 | The 0x7F sentinel and the field-VM prescript loop | 8003a1e4.txt |
| Pure-Y rotation | FUN_80026988 | Yaw sense (local +Z to (sin, 0, cos)) | 80026988.txt |
| Fog LUT apply | overlay 0x801F7644..0x801F8690 | Per-vertex scalar nudge from the 2048-entry LUT at gp−0x2BC | world-map overlay dump |
| Far colour | FUN_80043390 / FUN_801E3E00 | Three ctc2 writes; the atmosphere actor's per-frame RGB | 80043390.txt, overlay_801e3e00.txt |
| Ocean CLUT step | slot-5 CLUT-walk table | 16 × 1 copy onto (0, 506) every 9 vsyncs | legaia_asset::clut_walk; prim-trace clut=0x7E80 cluster |
| Camera origin | _DAT_80089118 / _DAT_80089120 | Spawn anchor per kingdom | mednafen-state world-map-camera |