At a glance

Input
A disc image in the browser tab. Save-state captures only refine camera anchors and the script-placed actors
Kingdom bundles
PROT 0086 Drake / 0245 Sebucus / 0392 Karisto; the walk .MAP is the first entry of each scene block - two below its CDNAME number - with a fixed 0x12000-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
Haze
Retail's walk-view depth cue (ground toward 0x100, trees toward 0xD0), measured from a virtual walk camera on the view centre
Mist
The kingdom MAN's fog-region table drawn through the field fog pool's rules: additive cloud sheets wherever an area's Mist has not lifted
Code
legaia_web_viewer::{build_walk_ground, build_walk_placements, world_mist, ocean, sentinel_placements, scene_export}; site/js/world-overview-app.js, world-overview-mist.js, webgl-tmd.js, webgl-math.js
Confidence
Heightfield, stamps and ocean: Confirmed against the engine and save-state VRAM. Haze and Mist follow retail's rules from the disassembly; the virtual walk camera and the Mist's whole-map spread are viewer conventions, as are the unplaced-mesh layout and global-pool placeholders
Used by
World overview · VR mode · Asset viewer (full-map mode)
Ocean backdrop flat quad, y = 0 Heightfield walk .MAP floors Decorations trees, mountains Landmarks towers, bridges Haze + Mist depth cue, fog sheets Palette row (0, 506) 13 frames, one write per step sea and water cells sample the same 16 entries
Draw order of the assembled view. One palette-row rewrite per animation step shimmers both the open sea and every water cell of the continent, phase-locked, because they read the same palette.

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:

PieceSourceSection
Continent groundWalk .MAP floor grid, generated the way the game doesheightfield
Landmarks and decorationsWalk-map per-cell recordsstamps
Distance hazeThe walk view's depth cue, fixed far colours in PROT 0901haze
MistMAN section-4 fog regions + the fog poolMist
OceanKingdom bundle slot 0 tile + 13-frame CLUT tableocean
Script-placed actorsSave-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_scene_formats::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.

LayerRecord ruleCount (Drake / Sebucus / Karisto)
Placed landmarksObjects with the placed flag 0x4, stamped on occupied tiles5 / 6 / 17
DecorationsEvery cell carrying the draw bit 0x2000 whose record is not placed, stamping the record's +0x10 mesh - trees, mountain groups, small props and the big enterable mountains~295 / ~240 / ~210
Not decorationsThe riverbank record family: a nonzero mesh slot and the walk bit only, never 0x2000 - 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 −lut into its vertices and lands at +lut under the shared (1, −1, 1) flip, so an un-negated anchor buries a stamp by 2 × (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 +Z to (sin, 0, cos), the opposite sense of the JS helper, hence rotY = −(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.
  • Mountains are ground. The big mountain cells carry the draw bit and no walk bit 0x1000 - the mesh is the ground there - so a decoration sweep gated on the walk bit draws every tree and drops every big mountain. Drake's terraced peak is record 412 at cell (39, 80).
  • Whole meshes. Each stamped mesh uploads its textured primitives and its untextured colour primitives together: Rim Elm's roofs are gouraud triangles, and the Uru Mais temple on Karisto is colour primitives only. Retail draws both families through one dispatch, and the distance haze applies to both.
  • 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_SUPPRESS drops it by (mesh, x, z).

Code: legaia_web_viewer::build_walk_placements over parse_placements + parse_walk_decorations. The viewer resolves exactly the placements the native engine draws, on all three kingdoms.

Distance haze

In the walk view, far ground pales toward white. That is a depth cue, not a coloured fog: PROT 0901's ground emitter moves each cell's colour toward a far colour of 0x100 by IR0 = (SZ − 0x5000) >> 3, and the decoration sweep moves each tree toward 0xD0 by its own depth. Both far colours are fixed in code; placed landmarks take no cue.

Every term keys on the walk camera's depth, and the overview's orbit camera is not that camera. So the page stands in a virtual walk camera on the centre of the view, as if the party stood there: SZ = eyeZ + 6 · cos(pitch) · distance, with the kingdom's own walk eye depth and pitch. On Drake the haze starts about 2300 units out and saturates near 8800, so the far half of the kingdom visibly pales. Retail measures depth along the view; the stand-in is radial.

DrawCueFar colour
Continent ground and its water cellsPer vertex0x100, saturating at 0xFF
Trees, mountains, propsOne value from the stamp's origin0xD0
Towns, towers, bridgesNone-
Ocean backdropThe ground's, so the sea stays one layer0x100
History: why the old "fog" toggle did nothing

The toggle mixed toward a hand-set per-kingdom tint by a "fog LUT" content-scanned out of SCUS_942.54. That 2048-entry ramp is the arctan table the game's atan2 (FUN_80019B28) reads, and the depth term the page fed it never passed 4% of the table, so nothing visible changed. The table the world-map prims do index by depth is the curvature buffer, which bends screen Y. And no per-kingdom haze colour exists: the routine once read as an "atmosphere" actor (FUN_801E3E00) is the keyframe script of the field script's attached light.

The Mist

The story's Mist is something else again. On the world map the game draws it with the same fog pool that fills a misty town (field ambient effects): the kingdom's scene script raises the pool and switches each of its MAN fog regions on while that region's story flag is clear. Particles spawn inside the first region that holds a tile, drift on the region's heading, fade in and out, and draw as two additive cloud half-sheets. When the story sets a region's flag - an area's Mist lifting - that region stops spawning.

KingdomRegionsStory flags
Drake100x19A, 0x2FA, 0x308, 0x2FD
Sebucus80x51A..0x51D
Karisto90x007, 0x526, 0x51E, 0x51F, 0x520, 0x521

The Mist toggle shows each kingdom before any area clears, keeping the game's region test, spawn record, fade, sheet growth, sheet art (read from your disc) and additive blend. The game keeps the pool in a window around the party, capped at 72 sheets; the page spreads the same density over the whole continent so every Mist area shows at once. From the overview's height a sheet - one tile tall, several wide - reads as a thin streak; zoom toward the ground to stand in the bank. Drake's white bank on the ridge north of Rim Elm is a separate effect (scene sprite sheets) and is not drawn.

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.

AssetWhereDetail
TextureKingdom 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 CLUTSame TIM, row (0, 506)256 BGR555 entries; only the first 16 are animated, the rest belong to other tiles on the row
Animation tableSlot 0, CLUT-only TIM records (slots 3-5 Sebucus / Karisto, 10-15 Drake), first frame 0x54 bytes in13 frames × 16 entries = 416 bytes, byte-identical across the three kingdoms; the peak 0x3D05 walks indices 0..7
CadenceKingdom bundle slot-5 CLUT-walk tableA 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_scene_formats::ocean::find_ocean_assets. 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 = 0 extending 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). buildWorldOrbitVp orbits 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.

Top-down map

Beside the 3D canvas the page draws the loaded kingdom as a 2D plan. It is the assembled scene's data seen from straight above, not a second data source: the page gathers it off the same accessors, in the same load, that the 3D draw list is built from.

LayerSourceDrawn as
Land outlineThe heightfield reduced to one cell per quad, less the cells on the water texture pages 0x1B / 0x1C - the heightfield covers the whole grid, sea includedA 128 × 128 land mask over the sea colour
DecorationsWalk .MAP stamps whose record has no placed flagSmall dots
Placed objectsStamps whose record carries flag 0x4Squares
Named placesThe kingdom's records of the location table trailing the kingdom MANCircles, labelled
  • One frame. The label table's coordinates are map << 7, the position the game's own map screen projects each name from. That is the walk frame the heightfield and the stamps are built in, so a label lands on its town with no transform. A disc-gated test asserts every label falls inside its own kingdom's heightfield bounds and that the three kingdoms partition the 29-record table.
  • Orientation and fit. Screen-right is world +X and screen-up is world +Z, the basis of the 3D scene's default camera. The plot is fitted to the bounding box of the land cells, widened to hold every marker, with a fixed margin and one scale for both axes, so a kingdom wider than it is tall is letterboxed, not stretched. Nothing is clamped to the canvas: a record with no position is not plotted.
  • Left out, and counted. The legend names what it does not plot: the scripted actors parked at the (0x7F, 0x7F) sentinel (below), and any stamp the 3D scene suppresses.
  • Names come from your disc. Place names are read when the kingdom loads and are never written into the site; the committed placement JSON carries coordinates, ids and counts only. Without a disc the canvas shows a prompt.
  • Only partition 1 is a placement. The kingdom MAN's three header counts are its three record partitions, and the placement walker runs over partition 1 alone - 8 / 7 / 19 actor records for the three kingdoms. Partition 2 is the scene's event scripts; read as placements, their script bytes give a zero tile X on almost every record and decode to a column of points down the map's west edge. The extractor stops at the partition boundary.

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.

ToolRole
scripts/mednafen/resolve_bulk_terrain.pyWalks 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.pyMerges the result into site/world-overview.json as bulk_terrain_placements; the regression digest hashes only the static placements
crates/web-viewer::sentinel_placementsRust port of the RAM-side resolver for downstream callers
scripts/pcsx-redux/resolve_actor_tmds.pyDrake'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

TableSourceUsed for
KINGDOM_CAMLoad-time map-origin globals, decoded by mednafen-state world-map-cameraDefault view when a kingdom tab opens
world-overview.json[kingdom].topview_camPer-kingdom capture written by resolve_bulk_terrain.py"Lock to retail top-view", preferred
KINGDOM_TOPVIEW_CAMHand-tunedFallback 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
  1. Read the active CDNAME label at 0x80084548 and confirm the kingdom (map01 / map02 / map03).
  2. Walk the seven actor-list heads at 0x8007C34C..0x8007C36C, chaining on each actor's +0x00 next pointer.
  3. Read position as three i16 at +0x14, flags at +0x10, the id byte at +0x50.
  4. 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_actor_formats::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

FunctionAddressWhat it provesDump / probe
Floor samplerFUN_80019278Bilinear floor height from the walk grid - the heightfield surfaceghidra/scripts/funcs/80019278.txt
Placement stamperFUN_8003A55CPlaced-flag objects, yaw copy into the rotation triple, MAN-gated spawns8003a55c.txt
MAN placement walkerFUN_8003A1E4The 0x7F sentinel and the field-VM prescript loop8003a1e4.txt
Pure-Y rotationFUN_80026988Yaw sense (local +Z to (sin, 0, cos))80026988.txt
Ground cuePROT 0901 0x801F8D7C..0x801F8DBC / 0x801F7200..0x801F7220IR0 = (SZ − 0x5000) >> 3 per cell, and per decoration from its TRZPROT 0901 disassembly
Fog poolFUN_801D629C / FUN_8003F3FC / FUN_8003F86CRegion test, spawn record, fade and half-sheets behind the Mistcrates/engine-field/src/fog_particles.rs
Far colourFUN_8005B7D8 at 0x801F729C / FUN_80043390The literal SetFarColor(0x100, 0x100, 0x100) the ground is cued toward; a mesh's own colour word as far colour when its depth argument is non-zeroPROT 0901 disassembly; autorun_overworld_ground_far_colour.lua
Ocean CLUT stepslot-5 CLUT-walk table16 × 1 copy onto (0, 506) every 9 vsyncslegaia_scene_formats::clut_walk; prim-trace clut=0x7E80 cluster
Camera origin_DAT_80089118 / _DAT_80089120Spawn anchor per kingdommednafen-state world-map-camera

See also