# Four-map visual import review

The new courses use their authored CSS BSP geometry and packed assets, not a
reconstruction from video. See `scripts/fixtures/new-maps-sources.json` for the
four exact BSP hashes and authors. The original asset authors retain their
rights; these converters do not grant redistribution rights. No Valve source
implementation was copied.

## Reproduce

After the pinned BSP download, run `scripts/import_boreas_visuals.py` for each
source with `--slug`, `--map-id`, `--author`, `--game-dir` pointing to the local
CSS installation, `--texture-format lossless-webp` and `--compressed-textures`. The Python environment
needs NumPy and Pillow. Source files stay in ignored `node_modules/.map-imports`;
the generated resources and metadata live under `public/maps`.

Example:

```powershell
python scripts/import_boreas_visuals.py node_modules/.map-imports/surf_kitsune.bsp --slug kitsune --map-id surf_kitsune --author Arblarg --game-dir 'C:/Program Files (x86)/Steam/steamapps/common/Counter-Strike Source' --texture-format lossless-webp --compressed-textures
```

`npx tsx scripts/capture-new-map-previews.ts` checks the actual game renderer
against the raw imported assets at local Vite port 4193 (override `UI_BASE_URL`
if needed). It saves four camera views per map (plus Kitsune's S1 spawn) and a browser report under
`test-results/new-map-visuals`, plus actual map screenshots for the menu in
`public/previews`. Cameras use positions and directions from the public CSS
recordings. They are visual tests, not evidence of playable command replays.
Set `CAPTURE_MAPS=kitsune` to refresh only that map's preview and targeted report.

`python scripts/validate_new_map_visuals.py --game-dir '<local CSS directory>'`
checks every exported texture against its original decoded VTF pixels, pinned
BSP identity, asset existence, binary mesh lengths/indices and finite vertex
data. This integrity check shares source decoders with import; it is not an
independent proof of CSS rendering parity.

## Importer corrections

- Source static-prop skin is a signed 32-bit integer. The shared reader had read
  those four bytes as a float, silently reducing nonzero families to zero. The
  corrected field restores 41 Demise instances using skin 1 or 3. Every other
  decoded static-prop field is unchanged.
- Studio geometry now selects the actual skin family and bodygroup. Materials
  are fetched only when referenced by that selected geometry, eliminating
  warnings for unused skins and unused texture downloads.
- Initial `prop_dynamic` and `prop_dynamic_override` poses are imported with
  original position, angles, model scale, skin and entity tint. Skeletal and
  entity-driven animation are not claimed. Aircontrol's three figures and
  all 134 Demise dynamic decorations were previously omitted. Current native
  CSS accepted the 101 v49 trees; their original model scales are preserved.
- Initial brush opacity and disabled state are respected. Kitsune's initially
  invisible secret artwork is no longer drawn as opaque geometry. Its secret
  button-driven visibility sequence remains outside the renderer's supported
  effects.
- Optional lossless WebP preserves full source texture resolution and every
  decoded RGBA byte, including transparent-pixel RGB (`exact=True`). The four
  imports passed byte-for-byte PNG/WebP comparisons for 557 common images.
  PNG remains the default for existing import commands; none of the previous
  three maps' exports were rewritten.
- Original DXT1/3/5 blocks and authored mipmaps are also exported to DDS without
  decoding, re-encoding or resizing. Desktop browsers with S3TC and S3TC sRGB
  support upload those blocks directly. Other browsers receive the lossless
  WebP fallback. Cube faces, sky preparation and non-S3TC source formats keep
  ordinary images. One-bit-alpha DXT1 keeps its alpha format.
- Native and fallback textures occupy separate packs. A client fetches common
  geometry and exactly one texture representation; it does not download both.
  Existing three-map manifests without alternatives retain their old delivery
  paths and exact resource hashes.

## Visual verification and performance

Sixteen still-camera views loaded successfully in an actual Edge Chromium
browser with the production renderer and no console or page errors. Every
referenced asset loaded. A binary audit verified finite vertex data, expected
buffer lengths and in-range triangle indices. The original screenshot review
missed Kitsune's omitted walls; successful loading and file integrity did not
establish correct visibility. The correction and stronger regression below
address that specific failure. The latest audit covers 428 geometry batches
and 570 textures with exact RGBA comparisons to the decoded source VTFs.

| Map | World faces | World batches | Decoded-path asset bytes | Notes |
| --- | ---: | ---: | ---: | --- |
| Demise | 19,381 | 50 | 116,939,954 | 2,040 displacements, 2,350 static props and 134 dynamic initial poses |
| Kitsune | 7,866 | 42 + 1 sky-depth | 2,857,444 | Original neon and black-wall materials; 108 sky portal faces; 69 initially hidden entity faces skipped |
| Aircontrol KSF | 6,522 | 123 | 7,151,909 | Ten displacements, one static model and three dynamic initial poses |
| Lux | 6,068 | 49 | 4,220,000 | Original lit cyan architecture |

Demise's 77 PNG textures were 137,634,524 bytes; equivalent lossless WebP textures
are 81,583,270 bytes, a 40.7% saving for that compared set. This does not reduce
decoded GPU texture memory or geometry detail. The existing pack builder still
groups assets into bounded, cacheable requests.

Demise's source texture set contains 182,004,992 pixels (about 694 MiB when
decoded to RGBA before mipmaps, though the renderer does not upload every texture).
The new native path avoids most of that expansion. The 67 native textures keep
130,246,656 bytes of compressed mip data instead of 819,315,980 bytes of equivalent
RGBA mip data, an 84.1% saving for that subset. These are asset-derived texture
payload estimates, not a measurement of total browser or GPU allocation.

Actual packed download sizes (including the visual manifest):

| Map | Native S3TC | Decoded fallback | Native / fallback requests |
| --- | ---: | ---: | ---: |
| Demise | 82,406,109 bytes | 103,691,945 bytes | 41 / 29 |
| Kitsune | 1,132,949 bytes | 1,238,251 bytes | 4 / 3 |
| Aircontrol KSF | 4,543,072 bytes | 5,631,064 bytes | 3 / 3 |
| Lux | 2,723,688 bytes | 2,875,938 bytes | 3 / 3 |

The build lists the union of both variants, which is larger than either client
download. DDS blocks are larger than decoded-image files before transport gzip
but compress well; retaining four-MiB decompressed pack bounds also limits peak
loader scratch space. Demise's actual native download is 20.5% smaller.

One Demise outdoor view submitted about 1.33 million triangles and 278 draws;
three interior views submitted roughly 355–379 thousand triangles and 42–100
draws. The other maps' inspected views submitted about 9–21 thousand triangles.
Renderer submission time was below 1 ms in these local checks, but that is CPU
submission time only, not a GPU frame-rate benchmark. Whole-course performance
and gameplay validation belong in the main map validation report.

`scripts/new-map-render-performance.ts` animates telemetry camera views through
each course for eight seconds, then switches through both Demise representations
on the same canvas/context. It asserts the exact requested pack URLs, successful
fallback and zero browser errors. The first complete local check observed about
360 FPS (the environment's apparent frame cap) for all native routes, p95 frame
interval 2.9 ms, with identical repeat-Demise geometry/texture/program counts.
These numbers describe this machine, not a minimum supported-device guarantee.
The decoded path exposed a late texture-upload stall; new-map fallback uploads
now occur during preparation before joining instead of when a ramp first enters
view. The targeted rerun sustained about 360 FPS for both Demise paths, p95
2.9 ms and maximum 3.0 ms over the measured route. Preparation took 7.06 seconds
for the fallback and 5.23 seconds for native. Both reused the same context;
geometry/program counts matched and no browser errors occurred. Reports are
`performance.json` and `performance-targeted.json` in `test-results/new-map-visuals`.

An additional browser run denied both S3TC extension queries instead of forcing
the loader option. Lux automatically fetched its decoded packs, loaded zero DDS
textures and rendered the complete camera route with no errors. The exact
network-request assertion passed; `performance-unsupported.json` records it.

Validation commands:

```text
python -m unittest discover -s tests -p test_native_dds.py
npx tsx --test tests/visual-packs.test.ts tests/compressed-textures.test.ts
npx tsx scripts/new-map-render-performance.ts
```

The analytic DDS fixtures distinguish all four mip levels and compression types,
including alpha DXT1, corruption/truncation, cube fallback, and invalid headers.
Pack tests reject mixed/mislabeled variants and independently reassemble every
asset. Export integrity compares all 129 native DDS files with the source blocks
in addition to the 570 decoded image checks. None of these format tests establishes
movement fidelity; that remains the movement and whole-course replay suite.

The reviewed integrity and browser results are retained in `fixtures/new-map-visuals/` (including the original fallback stall and the targeted result after fixing it). Re-running the scripts writes fresh results under ignored `test-results/new-map-visuals/`; review them before replacing the retained evidence.

## Kitsune room-visibility correction

The initial import visibly exposed other stages through authored black walls.
The user caught this after the initial visual review. There were two concrete
causes, both fixed using source geometry rather than hiding stages by number:

1. The importer excluded all `tools/*` materials except `toolswhite`. Kitsune's
   packed `TOOLS/TOOLSBLACK` is actually an opaque `UnlitGeneric` material with
   surface flags zero and an entirely black, fully opaque VTF. The BSP contains
   2,349 such faces. Respecting the compiled surface flags restores 2,343 visible
   faces; six belong to initially hidden entities. Kitsune now has 7,866 ordinary
   exported faces, 42 material batches and 24 texture images.
2. After restoring black walls, white S9 geometry still appeared behind the
   orange S2 room. A camera ray through screenshot pixel (675,45) reaches authored
   `tools/toolsskybox` face 7773 at 1,711 units, well before white S9 geometry at
   19,748 units. The old renderer omitted that portal. The importer now retains
   108 world sky faces in one optional depth-only mesh (322 triangles). It is
   drawn after the sky and before the normal world, retaining the sky colour
   while rejecting world geometry behind the authored plane. No collision,
   player motion, stage selection or invented walls are involved. The 22 sky
   faces inside the separate miniature sky area are not world portals.

`tests/test_visual_surface_flags.py` checks the pinned BSP, independent compiled
flags, packed material/image, restored geometry and the exact sky portal plane.
`scripts/probe-kitsune-visual-occlusion.py` preserves the source ray evidence,
with the same FOV, viewport and clamped pitch as the screenshot camera. The
browser capture includes a negative control: at the S2 camera it renders the
same map with and without the sky-depth mesh and counts white geometry in the
previously leaking region. This checks that the depth pass actually removes the
observed leak, beyond merely confirming metadata exists. The 180 by 120 pixel
region contained 959 white S9 pixels without the depth pass and zero with it;
the five inspected views had no browser errors. Results are retained in
`fixtures/new-map-visuals/kitsune-visibility.json`.

Source's [surface flags](https://github.com/ValveSoftware/source-sdk-2013/blob/master/src/public/bspflags.h)
distinguish sky, nodraw, hint, skip and trigger faces from ordinary rendered
surfaces. [VBSP EmitFace](https://github.com/ValveSoftware/source-sdk-2013/blob/master/src/utils/vbsp/writebsp.cpp#L438)
writes the oriented plane index and its parity into `side`; an additional normal
flip would be wrong. All Kitsune face indices match that encoding and all 130
sky faces belong to the world model. The optional sky path also applies brush
entity transforms when another import contains them.

A read-only seven-map audit found other renderable `toolsblack` faces in Demise
(118), Utopia (93) and Mesa (1). Those exports were deliberately not regenerated
as part of this Kitsune correction. Boreas has only 22 already-retained
`toolswhite` faces; Aircontrol and Lux have no renderable tool-material faces.
Their retained older exports therefore still have their previously documented
visual limitations. The sky-depth feature is likewise used only by regenerated
manifests; it does not silently change the previous maps.

## Native compatibility and remaining visual differences

- Demise's 101 MDL v49 trees are supported: the isolated native CSS build
  11003710 probe confirmed all 101 as live entities with distinct valid model
  indices and authored model bounds/scales (`fixtures/native-css-demise-model-probe.txt`).
  The v49 header/body/mesh fields used by the reader retain their offsets; the
  import no longer assumes the public SDK's version-48 constant excludes them.
- `liquidpack/water/unique/water_tar_beneath.vmf` is referenced by Demise but is
  absent from its packed files and the local CSS assets. The warning is retained;
  no replacement texture was invented.
- Animated material proxies, refraction/water screen-space passes, moving brush
  animation, skeletal animation, entity visibility changes, particles, laser
  beams and map soundscapes are not reproduced. Existing ambient audio is a
  separate game feature.
- Original diffuse lightmaps are retained. Source's directional bumped-lightmap
  basis, blend modulation, SSBump response and dynamic-prop lighting are not exact
  equivalents of the current renderer.

The public [Source SDK studio header](https://github.com/ValveSoftware/source-sdk-2013/blob/master/src/public/studio.h)
documents the model fields and uses model version 48; it does not by itself
establish compatibility with a particular current CSS binary. Import success or
a screenshot does not prove complete visual or movement parity with native CSS.
