refactor: resolved metre layout is the runtime authority (L41, migration step 3); stamp covers plugin sources; R8

UVoxelStrateManager::Initialize resolves the layout width to integer micrometres before passages or
scheduling; each slot stores physical [bottom, top) bounds, transition mode/width and passage settings;
chunk indices remain a floor-based compatibility projection. The Core adapter derives interiors and L29
bands from those resolved values. verify: full-tier cost baseline is HEAD (or --baseline), tier timeout
7200 s with 1800 s per process; the source stamp now includes the plugin Source tree compiled on Linux
(it previously missed VoxelStrateManager.cpp and friends). Owner hashes identical to L37; fast, medium
and full PASS on a clean rebuild. Unreal manager/adapter edits are uncompiled on Windows.
Research R8: inventory of unit-bearing UPROPERTYs and a custom-version asset migration plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-10-23 19:36:51 +02:00
1 parent efa9e89468
commit 1abee91721
11 files changed
+662 -215

No files matched your search

@@ -0,0 +1,45 @@
# Linux 41 — resolved metre layout
Migration step 3 is implemented as a pure refactor. The verifier, manager and Core adapter changes are in the working tree; nothing was committed or pushed.
## Tool changes
Full-tier cost now builds a stamped baseline from `HEAD` by default; `--baseline <commit>` selects another commit. The build uses a temporary source tree. `--overall-timeout` defaults to 7,200 seconds for the tier. Each child process is killed at 1,800 seconds; builds remain capped at 12 jobs and the runner applies the requested CPU and address-space limits.
## Runtime authority
`UVoxelStrateManager::Initialize` resolves the configured layout width to integer micrometres before passage generation or scheduling. Slot height and gap are legacy chunk counts multiplied by that width. Each slot stores its physical `[bottom, top)` bounds and height, transition mode and width, copied passage settings and their micrometre dimensions. The effective seed is captured before passage generation. The Core adapter derives its interior bounds and L29 facing bands from those resolved values before work is scheduled.
The existing 8 m layout remains exact: the first upper edge is +8,000,000 µm; each height, gap or transition chunk is 8,000,000 µm. Chunk bounds are floor-based compatibility projections, including at negative Z. Season v2 still accepts only aligned, correctly stacked chunk bounds, then maps them to the same physical intervals. L29's bands keep their original faces: ceiling `[top-seal, top]`, content `[bottom+seal, top-seal]`, and bedrock `[bottom, bottom+seal]`; `seal` is the existing boundary-seal thickness converted from owner voxels to micrometres. Shared density and transition behavior are unchanged.
R6’s FI/SW split was already resolved: both plans and the snapshot use the same `LayoutChunkWidthSubvoxels` derived from the layout width. At the 25 cm Core voxel size, 8 m remains 32 Core voxels (8,192 subvoxels). Layout bounds do not use an owner/Core ratio. Ratios remain only in the legacy FloatingIslands parameter adapter. The legacy passage geometry adapter converts resolved passage micrometres back to owner voxels at its boundary.
Chunk indices remain in chunk-keyed compatibility APIs: `GetStrateForChunk`, generator and parameter queries, season-v2 validation, and legacy generator dispatch. They read the cached projection; world-Z lookup and Core snapshot bounds use physical intervals. L40’s bound Core field table remains the per-sample dispatch path and adds no manager lookup.
## Results
The final source stamp is `f35bc59c6172370e98dc83ec8ee5ba51dfab83a3faf075c26189fc83172f4ac8`; artifacts are in `Saved/Linux/l41`. The four 12-record FI/SW worker-1/worker-12 hash signatures match L40 exactly: FI `9820f4f38b189b3f7263810e7b3ff62aa502b21f476ffcfdbc7761a65cb07d58`; SW `a1e9268a4bbcc77d2a1ac54095df6851a70bab65c43ae50b332ed75132617834`. W1 and W12 also match each other. Layout IDs are FI `b7a7d9ecc48d8f26` and SW `8a52e84c26ed92d2`.
| Tier | Result |
|---|---|
| Fast | PASS, 163.1 s; 12/12 hashes for each recipe, worker parity and L40 identity pass. |
| Medium | PASS, 469.0 s; all checks pass, including transition and passage gates. |
| Full | PASS, 1,567.1 s; 107 runs and 539 checks, including the 30-cell determinism gate and cost pairs. |
Full cost compares against `HEAD` `efa9e89468cbab702c86fcbe451c8836167ae83a`. FI median is 17.627 s versus 17.665 s (−0.22%); SW is 1.819 s versus 1.821 s (−0.11%). The paired samples vary by up to +2.29% for FI and +1.27% for SW; neither median regressed.
Failed attempts: an initial verifier edit had an `IndentationError`; an overlapping fast run hit a mouth-profile failure, then the serial fast run passed. The first full attempt passed its gates but failed when the baseline build’s output name did not match the expected path. The corrected baseline name was rebuilt from `HEAD`; the final full run passed. Expected negative-control rejections passed their checks.
Unreal changes are limited to `VoxelStrateManager.h/.cpp` and `VoxelForgeCoreAdapter.cpp`; `CODEMAP.md` and `ARCHITECTURE.md` §8.51 are updated. The Unreal manager and adapter could not be compiled in this Linux workspace; validate them with the Windows Unreal build before integration.
## Orchestrator review (Claude)
Accepted after a re-check. **The source stamp did not cover the plugin sources that `core_sources.txt` compiles into
the Linux binaries** (for example `Source/VoxelForge/Private/VoxelStrateManager.cpp`). So L41's stamp equalled
L40's, and the tiers could not prove that they ran the edited manager. Fixed in `source_stamp.py`: the whole plugin
`Source/VoxelForge` tree and `Standalone/Compat` are now always stamped. Clean rebuild of both compilers (new stamp
`15cd0a95…`): fast PASS (owner hashes identical to L37), medium PASS, full PASS (1569 s; cost vs HEAD FI +0.71%,
SW +0.55%, within noise).
Owner decisions since R8 (2026-10-23): converted assets may change size slightly, so clean metre values beat
lossless legacy conversion. Cache sharing between players is optional (opt-in, with a data budget).
@@ -0,0 +1,69 @@
# R8 — metre authoring in Unreal assets
Read-only source and UE documentation review. No source, build, or test changes.
## 1. Reflected spatial-property inventory
Conversion notation: `n` is the old serialized number. Use double precision and integer micrometres at the adapter boundary. `owner voxel` = 0.125 m (`VOXEL_SIZE`); `Core voxel` = that world's saved `CoreVoxelSizeCm / 100` (default 0.25 m); legacy runtime chunk = 32 owner voxels = 4 m; vertical *layout* chunk = the captured old `StrateLayoutChunkSizeMeters` (8 m default in current manager code). Frequencies are cycles per old voxel: `f_new_per_m = f_old / oldVoxelMeters`. Inverse-frequency fallbacks must be converted with the same route.
| Reflected fields (source line) | Current unit and use | Convert to metres / losslessness |
|---|---|---|
| `UVoxelStrateDefinition::StrateHeightInChunks` ( `VoxelStrateDefinition.h:75` ), `TransitionBlendChunks` (`:103`), `UVoxelSettings::InterStrateGapChunks` (`VoxelSettings.h:420`) | Integer vertical layout chunks; manager assigns slot bounds and transition bands. | `heightM = count × captured old layoutChunkM`; same for gap and transition width. Exact and yields the same chunk indices only when old effective width is known and new metres remain an integer multiple of it. Current manager uses `StrateLayoutChunkSizeMeters` (`VoxelSettings.h:427`, default 8 m); the strate comment says 4 m (`VoxelStrateDefinition.h:71–73`). This must be resolved from each owner's pre-update world, not guessed. |
| `UVoxelSettings::StrateLayoutChunkSizeMeters` (`VoxelSettings.h:427`) | Already metres; physical vertical-layout chunk width, independent of Core tile/voxel width. | Keep as metres. It is the basis for converting height, gap and transition values, not the 4 m legacy streaming chunk. |
| `ViewDistanceXY/Up/Down` (`VoxelSettings.h:44,47,50`), `PawnCollisionRadiusChunks` (`:96`), `StrateViewMarginChunks` (`:111`), `RenderDistanceChunks` (`:183`), `DecorationRadiusChunks` / `DecorationNearRadiusChunks` (`:241,248`); `FStrateLandmark::SpacingChunks`, `StreamRadiusChunks`, `ExclusionRadiusChunks`, `SuppressRadiusChunks` (`VoxelStrateTypes.h:2298,2335,2344,2360`) | Legacy chunk counts controlling streaming extents, collision work, content grids and landmark placement. These routes define one chunk as 4 m (32 × 12.5 cm); landmark/content code multiplies by the runtime chunk width. | `m = n × 4`. Exact for the old grid. Keep `StrateLayoutChunkSizeMeters` separate; it is not this chunk. If these knobs remain integer counts internally, recover `round(m/4)` only for the old 4 m grid. |
| `WorldRadiusVoxels`, `EdgeSealThickness`, `OriginSpineRadius`, `MaxBrushRadius` (`VoxelSettings.h:222,226,440,465`); decoration spacing/far spacing/march step/column depth (`:263,271,277,289`); `FDecoSubCompanion::RadiusMinVox/RadiusMaxVox` (`VoxelStrateTypes.h:2127,2130`), `FDecoCompanion::RadiusMinVox/RadiusMaxVox` (`:2168,2171`); landmark orb radius/falloff/max-shadow distance (`:2383,2388,2393`) | Owner Runtime voxels at 12.5 cm. Used as radii, bounds, placement spacing/search steps, or light ranges. | `m = n × 0.125`. Exact physical conversion for the 12.5 cm grid. The spacing/step fields need metre-aware sampling, not just renamed UI labels. |
| `FStrateGenerationParams` (`VoxelStrateTypes.h`: `WormNetworkRange` 458; `RoomSpacing` 479; `MinRoomRadius` 496; `MaxRoomRadius` 504; `FloorReliefStrength` 552; `RoomMouthRiseBlendVoxels` 571; `OriginRoomRadius` 593; `TunnelMinRadius` 615; `TunnelMaxRadius` 622; `MaxTunnelLength` 637; `TunnelWarpStrength` 649; `TunnelFloorMaxLedgeHeight` 708; `SDFBlendRadius` 739; `CaveWarpStrength` 762; `SurfaceRoughness` 786; `DomainWarpStrength` 815; `FloorBias` 840; `BoundarySealThickness` 1076) | Owner-voxel distances/displacements used by cave morphology, warps, roughness, tunnel/room envelopes and seals. | `m = n × 0.125`. Exact for physical lengths. `TunnelFloorTerraceStepHeight` (`:702`) is serialized but explicitly ignored; keep it as deprecated compatibility data instead of converting a dead control. `TunnelEndpointZOffset` (`:678`) and `TunnelFloorGentleSlopeThreshold` (`:716`) are ratios/degrees, not lengths. |
| `FStrateGenerationParams` frequencies (`VoxelStrateTypes.h:418,560,771,793,822`) | `WormFrequency`, `FloorReliefFrequency`, `CaveWarpFrequency`, `RoughnessFrequency`, `DomainWarpFrequency`; inverse owner-voxel wavelength. | `cycles/m = f / 0.125` (multiply by 8). Exact change of units; resulting sampled output is byte-identical only if the evaluator uses the same operation/rounding order. |
| `FSlabGenerationParams` (`VoxelStrateTypes.h`: `FloorRoughness` 1259; `ColumnMinRadius` 1311; `ColumnMaxRadius` 1317; `ColumnSpacing` 1324; `CeilingRoughness` 1280; `BoundarySealThickness` 1334) and `FMazeGenerationParams` (`:CellSize` 1371; `CorridorRadius` 1377; `SurfaceRoughness` 1391; `BoundarySealThickness` 1395) | Owner-voxel geometry: surfaces, formations, columns, cells, corridors and seals. | `m = n × 0.125`, exact physically. Slab frequencies at `:1266,1288` use `cycles/m = f/0.125`. Relative floor/ceiling heights, density and probabilities remain dimensionless. |
| `FSurfaceGenerationParams` geometry (`VoxelStrateTypes.h`: `ElevationRange` 1430; `SurfaceRoughness` 1452; `HeightWarpStrength` 1460; `TerraceHeight` 1490; `LayerLineDepth` 1509; `LayerLineSpacing` 1513; `CliffSampleDist` 1537; `OverhangReach` 1554; `OverhangHeight` 1560; `BeachWidth` 1588; `CeilingRoughness` 1599; `CeilingUndulation` 1613; `CeilingRidgeStrength` 1625; `CeilingWarpStrength` 1635; `BoundarySealThickness` 1643) | Voxel lengths/displacements. **Current Core SurfaceWorld adapter consumes these raw in Core-voxel units** via `WorldAssemblyFixed`; the legacy owner path is 12.5 cm, while the current Core default is 25 cm. | To preserve the current Core recipe, `m = n × captured CoreVoxelSizeCm/100` (default `n×0.25`), not `n×0.125`. If preserving a legacy Runtime route instead, use that route's 0.125 m scale. This route distinction is essential. |
| `FSurfaceGenerationParams` frequencies (`VoxelStrateTypes.h:1434,1443,1447,1464,1470,1565,1604,1617,1629,1639`) | Continent, mountain, detail, height-warp, relief, overhang, ceiling roughness/undulation/ridge/warp frequencies. Current SurfaceWorld adapter treats them as cycles per Core voxel. | `cycles/m = f / captured CoreVoxelMeters` (×4 at 25 cm, ×8 at 12.5 cm). Use `fallbackM = fallbackOldVoxels × CoreVoxelMeters` when old frequency is zero. Exact unit conversion; see adapter caveat below. |
| `FVerticalShaftParams` (`VoxelStrateTypes.h`: `ShaftSpacing` 1672; radii 1680,1683; `ConnectorRadius` 1691; `LedgeSpacing` 1696; `LedgeDepth` 1700; `SurfaceRoughness` 1704; `BoundarySealThickness` 1708), `FFloatingIslandParams` (`:IslandSpacing` 1737; radii 1746,1749; roughness 1766; `SDFBlendRadius` 1771; seal 1775), `FStrateDisturbanceParams` (`:ChasmSpacing/Radius` 1808,1810; bridge spacing/radius 1817,1819; ridge spacing/height/thickness 1826,1828,1830; seal 1838) | Owner voxels; FI comments explicitly define owner scale and current adapter converts FI dimensions by owner/Core ratio. | `m = n×0.125`; exact. Disturbance and shaft spacing become metres too. Density, thickness ratio and jitter remain unitless. |
| `FStratePassageConfig` (`VoxelStrateTypes.h`: `MouthRadius` 1886; `MidRadius` 1889; `ReachMin/Max` 1895,1898; `DistanceMin/Max` 1904,1907; `Wander` 1913; `VerticalWobble` 1922; `SpiralRadius` 1927; `CascadeLedge` 1938) | Owner voxels; current Core adapter explicitly maps these through `OwnerVoxelsToCoreQ`. | `m=n×0.125`; exact. `Connections`, `Segments`, `CascadeSteps` are counts; `SpiralTurns` is turns, so keep those. The adapter's fixed 12-owner-voxel bore/wall reserves also need to become explicit 1.5 m / 0.5 m constants. |
| `FBiomeMapParams` (`VoxelBiomeTypes.h`: `CellSize` 37; `WarpStrength` 42; `BorderBlend` 51; frequencies 46,63,71) | Widths and warp displacement are voxel units; current biome recipe adapter packs them as Core voxels. Climate/warp frequencies are inverse Core-voxel units on that route. | Width/displacement: `m=n×CoreVoxelMeters`; frequency: `cycles/m=f/CoreVoxelMeters`. The legacy owner evaluator instead requires 0.125 m. `ReliefContrast` is unitless. |
| `UVoxelTerrainOpDefinition` (`VoxelTerrainOpDefinition.h`: `TerraceStepHeight` 91; `LayerLineSpacing` 112; `RibbingSpacing` 127; `OverhangDepth` 174; arch radii 195,201; column radii 218,224; pit radii/depth 241,247,253; chimney radii/height 270,276,282; dome radii 297,303; `PinchStrength/PinchLength` 328,334) | Legacy owner-voxel distances and SDF radii/lengths; `PinchStrength` is a physical narrowing width in morphology. | `m=n×0.125`. `ScallopFrequency` (`:159`) and `OverhangFrequency` (`:179`) become `f/0.125` cycles/m. `LayerLineDepth` (`:118`) and `RibbingDepth` (`:133`) are density amplitudes, not distances; leave them outside metre conversion. |
| `FPlacementProfile::SurfaceOffset`, `LocationOffset`, `CullDistance` (`VoxelStrateTypes.h:2068,2072,2097`); `UVoxelStrateDefinition::CeilingLayerZOffset/FloorLayerZOffset` (`VoxelStrateDefinition.h:316,329`); `AVoxelWorld::CoreVoxelSizeCm` (`VoxelWorld.h:304`) | Unreal centimetres. Placement vector is post-snap world offset; layer offsets are vertical offsets; Core voxel field is a world resolution (12.5–25 cm). | Scalar/vector centimetres: `/100` per component. Core resolution: `CoreVoxelSizeMeters=cm/100`. Exact at micrometre precision for current values. Do not bake this world-level value into a shared strate asset if different worlds use different Core sizes. |
| `EditorBrushCenter`, `EditorBrushRadius`, `BiomePreviewWorldSize`, `BiomePreviewCenter` (`VoxelWorld.h:862,866,902,906`) | Brush center is Unreal cm; brush radius and preview window size/center are legacy owner-voxel coordinates. | Center cm `/100`; other values/components `×0.125`. `BiomePreviewCenter` is a coordinate, not a size, but its units still change. |
| `UVoxelSeasonAsset` mirrors (`VoxelSeasonAsset.h:56,59`) and `VoxelSettings::WorldAssemblyManifestJson` (`VoxelSettings.h:392`) | Reflected mirror values are voxel-valued; the JSON is serialized manifest text, not a scalar property. Manifest schema has `HeightInChunks`, `InterStrateGapChunks`, `OriginSpineRadius`, `WorldRadiusVoxels` and per-strate `HeightInChunks` (`VoxelSeasonManifest.h:88–91,164`). | Migrate the source JSON with its own schema-version step, using the same route-aware formulas; refresh mirrors and content hash. Do not multiply opaque JSON text or convert mirrors independently. CSV/DataTable import sources also need a separate migration. |
`ClipRadius` (`VoxelSettings.h:153`) and `AVoxelWorld::CoreClipRadius` (`VoxelWorld.h:308`) are tile counts, `SkirtCells` (`VoxelSettings.h:193`) scales with LOD step, and `CoarseTileCells`/clip levels/region sizes are algorithmic counts. They have no single metre conversion that preserves current semantics across LODs; keep as counts or redesign the algorithm around a physical extent. Runtime-only `FVoxelModification` in `VoxelDiffLayer.h` is not reflected (`UPROPERTY`) and is not in this inventory.
**Losslessness:** 0.125/0.25 m voxels and 4/8 m chunks map exactly to integer micrometres. The Core recipe uses 1/256 Core-voxel fixed point; old values reproduce its old integer Q only when converted from the actual route and old world resolution. Arbitrary new metre values round to that Q grid, so they are not universally lossless. Use one `llround` at the adapter boundary and compare the resulting integer fields.
## 2. UE5 migration mechanism
Use a plugin `FGuid` custom version registered with a static `FCustomVersionRegistration`; migrate loaded properties in each owning UObject's `Serialize`. UE documents this GUID/version pattern and `FArchive::UsingCustomVersion` for object-level asset conversion ([asset/package versioning](https://dev.epicgames.com/documentation/unreal-engine/versioning-of-assets-and-packages-in-unreal-engine?lang=en-US), [`FCustomVersionRegistration`](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/FCustomVersionRegistration), [`UsingCustomVersion`](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Core/FArchive/UsingCustomVersion), [`UObject::Serialize`](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/UObject/Serialize)).
```cpp
// Registered once in the plugin module; increment Version when a later migration is added.
Ar.UsingCustomVersion(FVoxelForgeAssetVersion::GUID);
Super::Serialize(Ar);
if (Ar.IsLoading() && Ar.CustomVer(FVoxelForgeAssetVersion::GUID) < MetersV1)
{
HeightMeters = double(StrateHeightInChunks_DEPRECATED) * CapturedOldLayoutChunkMeters;
TransitionWidthMeters = double(TransitionBlendChunks_DEPRECATED) * CapturedOldLayoutChunkMeters;
// Convert each nested parameter by its recorded legacy route, then mark schema state.
}
```
Run this for settings, strate, biome, terrain-op, and world owners. A world-specific `CoreVoxelSizeCm` and old effective layout-chunk width must be available to the converter; do not infer either from current CDO defaults. Existing `UVoxelStrateDefinition::PostLoad` is useful for object cleanup, but `PostLoad` is not called for newly-created objects ([UE `PostLoad`](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/CoreUObject/UObject/UObject/PostLoad?application_version=5.5)). `PostEditChangeProperty` is for editor reactions/previews after a user edit, not load migration; use it only to refresh validation/UI when a new metre field changes ([property-change event API](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/CoreUObject/FPropertyChangedEvent)).
For one transition release, keep each old reflected property as a deprecated `_DEPRECATED` field with the old type and old CDO default, and add the new `...Meters` property with `meta=(Units="m")`. On rename, use exact `PropertyRedirects` from the old serialized name to its deprecated holding property, then copy/convert in `Serialize`; redirects remap names/values on load but do not change units ([Core Redirects](https://dev.epicgames.com/documentation/en-us/unreal-engine/core-redirects-in-unreal-engine)). Keep deprecated BP references compilable until graphs are updated. Epic notes metadata is editor-only, so runtime conversion must use the actual numeric field, not `Units` metadata ([metadata specifiers](https://dev.epicgames.com/documentation/unreal-engine/metadata-specifiers-in-unreal-engine)). Keep the old CDO defaults for this release: old assets may omit values equal to their old CDO default. Convert old-version assets even when the new field has its default; do not mistake that for already migrated data.
For `USTRUCT` rows in DataTables, add a version-aware `PostSerialize(const FArchive&)` via `TStructOpsTypeTraits::WithPostSerialize`, or run the same conversion in a one-time editor asset upgrader. Reimported CSV/JSON is external source data and will overwrite the table, so migrate that source too. Keep redirects and deprecated fields through a full resave/reference audit, then remove them in a later version.
## 3. Owner checklist
- Back up the Windows project/source control state. Before updating, record each world's old `CoreVoxelSizeCm`, effective layout-chunk metres, settings, seed and exported `ResolvedLayoutSnapshotV3` (strata bounds, gaps, transition widths, passage Q values and hash). This resolves the code's 4 m comment / current 8 m default conflict.
- Update the plugin, open and compile affected Blueprints, then load/save every referenced Settings, Strate, Biome, TerrainOp, Season, world/level and DataTable asset. Check conversion logs and save every dirty package. An asset never loaded and saved still has its old on-disk values; cooking can consume an in-memory conversion but is not a source-asset resave.
- Replace old Blueprint property nodes and fix deprecation/redirect warnings. Convert CSV/JSON sources before reimport; verify Season manifest schema and content hash refreshed.
- Re-export the same worlds with the same seed. Compare integer micrometre bounds, transition fields, passage fixed-point values, recipe Q/wavelength fields and `ResolvedLayoutSnapshotV3` stable identity against the pre-update export; inspect a clean cook/reopen for missing assets and redirect warnings.
## 4. Core adapter mapping
- `AttachValidatedWorldAssembly` currently resolves layout chunk micrometres once (`VoxelForgeCoreAdapter.cpp:818–822`); snapshot interiors and transition width use chunk indices × that width (`:1098–1108`). Feed it metre-valued bounds/gap/transition as integer micrometres. For converted legacy assets, convert back to the same chunk indices and preserve the same integer fields/hash. Reject or extend the Core snapshot schema for heights that are not integral layout chunks; the current manager/resolved layout is chunk-indexed.
- Replace `WorldAssemblyFixed(value)` (`:67–71`, currently `round(value×256)` voxels) with `MetersToCoreQ(m) = round(m×1,000,000×256/CoreVoxelMicrometres)`. Replace `WorldAssemblyWavelength(f, fallbackVoxels)` (`:78–83`) with a metre-frequency form: `MetersToCoreQ(f>0 ? 1/f_cyclesPerMeter : fallbackMeters)`. For old SurfaceWorld and biome recipes, `n Core voxels → n×CoreVoxelMeters`, so current Q remains `n×256`; old frequency `f` becomes `f/CoreVoxelMeters`, preserving its wavelength Q.
- For Floating Islands and passages, current code uses the 12.5 cm owner/Core ratio (`:492–524`, `:1061–1085`). Convert old owner values once to `n×0.125m`, then call `MetersToCoreQ`; at 25 cm Core this yields the same `n×128` Q as the existing ratio. Do not multiply by the ratio a second time. Convert hard-coded 12/4 owner-voxel bore/reserve constants to 1.5/0.5 m.
- Set resolved `voxelSizeMicrometres` from `round(voxelSizeMeters×1e6)` (`:1098`). Current runtime still has one actor-level Core sampling size and writes that same size for every strate; until Core sampling supports mixed per-strate resolutions, validate that every resolved strate value equals the world's `CoreVoxelSizeCm/100` (normally 0.25 m) rather than publishing conflicting metadata.
- Exact parity means the converted adapter emits the same old integer Q, micrometre intervals and canonical V3 layout identity for each old asset. Keep the old schema path/rounding for converted assets until that comparison passes.
**Owner question:** Can you capture each world's pre-update layout export, including its effective layout chunk width and `CoreVoxelSizeCm` (especially any world not at 25 cm)?
@@ -0,0 +1,72 @@
# Unreal asset migration notes — resolved metre layout
Read-only schema review for L41. No Unreal source or assets changed; no build or tests run.
## Existing authored data
- `UVoxelStrateDefinition` is a `UPrimaryDataAsset`. Its persisted `StrateHeightInChunks` and
`TransitionBlendChunks` drive the vertical slot and lower-boundary transition. Their comments
still describe 4 m chunks, while `UVoxelSettings::StrateLayoutChunkSizeMeters` now defaults to
8 m. Keep the numeric fields as legacy authoring inputs and resolve them once using the layout
width selected for that world. Do not reinterpret the stored integers as metres or infer their
scale from the stale tooltip.
- `UVoxelSettings::InterStrateGapChunks` is another legacy chunk input. Per-strate
`FStratePassageConfig` is persisted with radii, reaches, placement distances, wander, wobble,
spiral radius and cascade ledge expressed in legacy voxels; style, connection count, and segment
counts are dimensionless/discrete. Retain the exact old effective voxel scale for each legacy
asset route, convert dimensional values once, then store the resolved physical passage request
and derived seed before any jobs are scheduled.
- L40/R6 confirms FI and SW already receive the same `LayoutChunkWidthSubvoxels` for layout
planning. Never apply an owner/Core ratio to layout bounds a second time. Keep that ratio only
in the legacy FI parameter adapter; convert passage and layout inputs at their own established
unit boundary.
## Season assets need a separate compatibility route
`UVoxelSeasonAsset` embeds the reviewed JSON plus copied schema/season/seed/hash metadata. Current
manifest schema v2 stores `height_in_chunks`, global `inter_strate_gap_chunks`, per-slot seed and
`top_world_z`/`bottom_world_z`; the composer derives those bounds with a fixed `* 32` convention.
The runtime validates those stored bounds against the live layout chunk width. V2 does not carry a
resolved transition width/band or passage config for generated slots; authored slots recover
content and passage settings through `source_definition_path`. The asset loader rejects a schema
version it does not recognize and verifies both hash copies.
For migration, keep a v2 reader/converter that interprets v2 under its original coordinate
convention, verifies its existing content hash first, then builds the new immutable metre snapshot.
Do not rewrite the embedded v2 JSON or silently add default passages: either action changes
identity or geometry. If a season is explicitly re-authored/exported in a new schema, include
resolved metre bounds, gap, interface transition/band records, per-strate seed and passage settings;
bump the manifest schema and generate a new canonical content hash. Record the source schema and
conversion policy in diagnostics so stale or ambiguous assets fail closed rather than guessing.
## Resolved snapshot and edge rules
The game-thread director should publish integer micrometre records before queueing work: half-open
strate bounds `[bottom, top)`, height, inter-strate gap, transition width, the exact L29 upper/lower
band bounds and mode, world/strate/passages seeds, and fully resolved passage descriptors. Copy
the L29 band endpoints and cap rules exactly; do not recompute a second approximation from chunk
indices in the adapter. Derive legacy chunk fields only as projections for streaming, old Blueprint
queries and logging.
Use one conversion and edge policy in manager and adapters. For an aligned half-open interval the
first chunk is `floor(bottom / chunkWidth)` and the inclusive last chunk is `ceil(top / chunkWidth)
- 1` (equivalently `top / chunkWidth - 1` when aligned). Use mathematical floor / Euclidean
division for negative Z, not C++ truncation toward zero. Quantize authored metres to integer
micrometres once with a documented tie rule; avoid converting through float chunk positions and
rounding again. Test exact lower/upper edges, one micrometre either side, negative boundaries,
zero gap, and adjacent bands in both adapter and manager.
## Version and identity risks
- Keep authored-schema version, resolved-layout schema, recipe/evaluator version, and source
content hash distinct. A derivation-only schema change may alter layout diagnostics; it must not
move the owner density/mesh hashes in this pure-refactor step.
- For old chunk/voxel assets, preserve physical dimensions with a versioned compatibility
conversion. Future metre-authored properties should be explicit and versioned; do not overwrite
old fields or treat their raw values as metres. Keep original asset paths/values available for
rollback and audit.
- `FStratePassageConfig` currently has no persisted passage seed, and season v2 lacks generated-slot
passage descriptors. Derive seeds with the existing stable world-seed/slot/connection salts and
record the result in the snapshot; do not base it on UObject pointer or load order.
- UHT/UBT validation is still required for any reflected schema/property migration. The Linux Core
checks cannot validate UObject serialization, cooked season references, or Blueprint compatibility.
+3 -1
View File
@@ -55,8 +55,10 @@ def first_stamp(text: str) -> str | None:
def bounded_run(command: list[str], *, timeout: int = 1800,
cwd: Path = ROOT, env: dict[str, str] | None = None,
stdout: Any = subprocess.PIPE) -> subprocess.CompletedProcess[str] | None:
timeout = min(1800, max(1, int(timeout)))
try:
return subprocess.run([*LIMIT, "timeout", f"{timeout}s", *map(str, command)],
return subprocess.run([*LIMIT, "timeout", "--signal=KILL", f"{timeout}s",
*map(str, command)],
cwd=cwd, env=env, stdout=stdout, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", timeout=timeout,
check=False)
+11 -3
View File
@@ -36,10 +36,18 @@ def manifest_entries(root: Path, include_unreal: bool = False) -> list[tuple[str
if helper.is_file():
files.add(helper)
# Plugin sources listed in core_sources.txt are compiled into the Linux binaries, and their
# headers are included by Core, so the whole plugin Source tree is always part of the stamp.
plugin_source = root / "Source" / "VoxelForge"
for suffix in ("*.h", "*.hpp", "*.cpp", "*.inl"):
files.update(plugin_source.rglob(suffix))
compat = standalone / "Compat"
for suffix in ("*.h", "*.hpp", "*.cpp"):
files.update(compat.rglob(suffix))
if include_unreal:
unreal = source / "VoxelForge"
for suffix in ("*.h", "*.hpp", "*.cpp", "*.cs"):
files.update(unreal.rglob(suffix))
for suffix in ("*.cs",):
files.update(plugin_source.rglob(suffix))
entries = [(path.relative_to(root).as_posix(), path.read_bytes())
for path in files if path.is_file()]
+59 -34
View File
@@ -10,6 +10,7 @@ import os
from pathlib import Path
import re
import shutil
import signal
import statistics
import struct
import subprocess
@@ -22,12 +23,16 @@ ROOT = Path(__file__).resolve().parents[3]
VERIFY_DIR = ROOT / "Standalone/Tools/verify"
LINUX37 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux37"
OWNER = ROOT / "Standalone/data/owner_world.json"
OUT_DEFAULT = ROOT / "Saved/Linux/l39"
OUT_DEFAULT = ROOT / "Saved/Linux/l41"
PARSER = argparse.ArgumentParser(description=__doc__)
PARSER.add_argument("tier", choices=("fast", "medium", "full"))
PARSER.add_argument("--out", type=Path, default=OUT_DEFAULT,
help="artifact directory (default: Saved/Linux/l39)")
help="artifact directory (default: Saved/Linux/l41)")
PARSER.add_argument("--baseline", default="HEAD",
help="commit used for the full-tier cost baseline (default: HEAD)")
PARSER.add_argument("--overall-timeout", type=int, default=7200,
help="maximum tier wall time in seconds (default: 7200; each child is capped at 1800)")
ARGS = PARSER.parse_args()
OUT = ARGS.out.resolve()
os.environ["VOXELFORGE_VERIFY_OUT"] = str(OUT)
@@ -40,9 +45,9 @@ from runner import VerificationFailure, VerificationRunner, bounded_run
from source_stamp import source_stamp
CLANG = OUT / "bin/VoxelForgeCoreHeadless-l39-clang"
GCC = OUT / "bin/VoxelForgeCoreHeadless-l39-gcc"
RENDERER = OUT / "bin/VoxelForgeOffscreenCapture-l39-clang"
CLANG = OUT / "bin/VoxelForgeCoreHeadless-l41-clang"
GCC = OUT / "bin/VoxelForgeCoreHeadless-l41-gcc"
RENDERER = OUT / "bin/VoxelForgeOffscreenCapture-l41-clang"
HASH_RE = re.compile(r"^\[Pipeline(?:FullDetailHash|WholeViewHash|LODHash|ExactQueryHash)\].*$", re.M)
RECIPE_RE = re.compile(
r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", re.M)
@@ -481,19 +486,26 @@ def run_30_cell_determinism(runner: VerificationRunner) -> None:
runner.require("determinism-30-cell-count", count == 30, f"cells={count}/30")
def prepare_l36_cost_baseline(runner: VerificationRunner) -> tuple[Path, str]:
temp = Path("/tmp/voxelforge-l36-stamped")
def prepare_cost_baseline(runner: VerificationRunner) -> tuple[Path, str]:
resolved = bounded_run(["git", "rev-parse", "--verify", f"{ARGS.baseline}^{{commit}}"], timeout=120)
if resolved is None or resolved.returncode != 0:
raise VerificationFailure(f"could not resolve cost baseline commit {ARGS.baseline!r}")
baseline_commit = resolved.stdout.strip()
if not re.fullmatch(r"[0-9a-f]{40,64}", baseline_commit):
raise VerificationFailure(f"git returned an invalid baseline commit: {baseline_commit!r}")
temp = Path(f"/tmp/voxelforge-cost-baseline-{baseline_commit[:12]}")
if temp.exists():
shutil.rmtree(temp)
temp.mkdir(parents=True)
tarball = temp / "l36.tar"
tarball = temp / "baseline.tar"
archive = bounded_run(["git", "archive", "-o", str(tarball),
"8be1c4d", "Standalone"], timeout=120)
baseline_commit, "Standalone"], timeout=120)
if archive is None or archive.returncode != 0 or not tarball.is_file():
raise VerificationFailure("could not export historical L36 source commit 8be1c4d")
raise VerificationFailure(
f"could not export Standalone from cost baseline commit {baseline_commit}")
extract = bounded_run(["tar", "-xf", str(tarball), "-C", str(temp)], timeout=120)
if extract is None or extract.returncode != 0:
raise VerificationFailure("historical L36 source extraction failed")
raise VerificationFailure("cost baseline source extraction failed")
tarball.unlink(missing_ok=True)
base = temp / "Standalone"
@@ -508,55 +520,57 @@ def prepare_l36_cost_baseline(runner: VerificationRunner) -> tuple[Path, str]:
"#include \"SourceStamp.h\"\n#include <cstring>\n#include \"OwnerWorldLoader.h\"", 1)
anchor = re.search(r"int main\(int argc,\s*char\*\*\s*argv\)\s*\{", text)
if anchor is None:
raise VerificationFailure("cannot instrument historical L36 headless entry point")
raise VerificationFailure("cannot instrument cost-baseline headless entry point")
injected = ("\n VoxelForgePrintSourceStamp();\n"
" if(argc==2&&std::strcmp(argv[1],\"--print-source-stamp\")==0)return 0;")
text = text[:anchor.end()] + injected + text[anchor.end():]
legacy_main.write_text(text, encoding="utf-8")
baseline_stamp = source_stamp(temp)
binary = OUT / "historical-bin/VoxelForgeCoreHeadless-l36-stamped"
baseline_variant = f"baseline-{baseline_commit[:12]}-stamped"
binary = OUT / "historical-bin" / f"VoxelForgeCoreHeadless-{baseline_variant}"
env = os.environ.copy()
env["VOXELFORGE_BUILD_ROOT"] = str(temp / "build")
env["VOXELFORGE_BIN_DIR"] = str(binary.parent)
command = ["bash", str(base / "BuildLinux.sh"), "--compiler=clang++-19",
"--variant=l36-stamped", "--fp-contract=off", "--jobs=12"]
log = OUT / "cost-baseline-build-l36.log"
f"--variant={baseline_variant}", "--fp-contract=off", "--jobs=12"]
log = OUT / "cost-baseline-build.log"
log.parent.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
with log.open("w", encoding="utf-8") as stream:
proc = bounded_run(command, timeout=1800, env=env, stdout=stream)
elapsed = time.monotonic() - started
runner.require("cost-baseline-l36-build",
runner.require("cost-baseline-build",
proc is not None and proc.returncode == 0 and binary.is_file(),
f"source_stamp={baseline_stamp} elapsed_seconds={elapsed:.1f} log={log}")
f"baseline={baseline_commit} source_stamp={baseline_stamp} "
f"elapsed_seconds={elapsed:.1f} log={log}")
runner.binary_stamp_overrides[str(binary)] = baseline_stamp
return binary, baseline_stamp
def run_cost_pairs(runner: VerificationRunner) -> None:
baseline, baseline_stamp = prepare_l36_cost_baseline(runner)
baseline, baseline_stamp = prepare_cost_baseline(runner)
candidate = CLANG
orders = (("l36", "l39"), ("l39", "l36"), ("l36", "l39"),
("l39", "l36"), ("l36", "l39"))
orders = (("baseline", "candidate"), ("candidate", "baseline"),
("baseline", "candidate"), ("candidate", "baseline"),
("baseline", "candidate"))
by_recipe: dict[str, dict[str, list[float]]] = {
"floating-islands": {"l36": [], "l39": []},
"surface-world": {"l36": [], "l39": []}}
"floating-islands": {"baseline": [], "candidate": []},
"surface-world": {"baseline": [], "candidate": []}}
by_pair: dict[tuple[str, int], dict[str, float]] = {}
for recipe, style in (("floating-islands", 2), ("surface-world", 1)):
for pair, order in enumerate(orders, 1):
for build in order:
binary = baseline if build == "l36" else candidate
expected_style = None if build == "l36" else style
binary = baseline if build == "baseline" else candidate
name = f"cost-{recipe}-pair{pair}-{build}"
expected_stamp = baseline_stamp if build == "l36" else None
expected_stamp = baseline_stamp if build == "baseline" else None
row, output = runner.run_cpp(name, binary, OWNER, recipe, [],
expected_style=2 if build == "l39" else None,
expected_strate_styles={"0": 2, "1": 1} if build == "l39" else None,
expected_style=2 if build == "candidate" else None,
expected_strate_styles={"0": 2, "1": 1} if build == "candidate" else None,
layout_path=OUT / "layouts" / f"{name}.json",
expected_stamp=expected_stamp)
row["requested_style"] = style
row["expected_passage_style"] = 2 if build == "l39" else None
row["expected_passage_style"] = 2 if build == "candidate" else None
ready = re.findall(r"\bwhole_view_ready=([0-9.]+)s", output)
ok = (len(ready) == 1 and "hash=off" in output
and "cache_hits=0" in output
@@ -567,14 +581,14 @@ def run_cost_pairs(runner: VerificationRunner) -> None:
by_recipe[recipe][build].append(float(ready[0]))
by_pair.setdefault((recipe, pair), {})[build] = float(ready[0])
for recipe, values in by_recipe.items():
old = statistics.median(values["l36"])
new = statistics.median(values["l39"])
paired = [((by_pair[(recipe, pair)]["l39"] - by_pair[(recipe, pair)]["l36"])
/ by_pair[(recipe, pair)]["l36"] * 100.0)
old = statistics.median(values["baseline"])
new = statistics.median(values["candidate"])
paired = [((by_pair[(recipe, pair)]["candidate"] - by_pair[(recipe, pair)]["baseline"])
/ by_pair[(recipe, pair)]["baseline"] * 100.0)
for pair in range(1, 6)]
runner.require(f"cost-{recipe}-five-pairs",
len(values["l36"]) == len(values["l39"]) == 5,
f"median_l36_s={old:.3f} median_l39_s={new:.3f} "
len(values["baseline"]) == len(values["candidate"]) == 5,
f"median_baseline_s={old:.3f} median_candidate_s={new:.3f} "
f"median_change_pct={(new / old - 1.0) * 100.0:+.2f} "
f"paired_change_pct={','.join(f'{value:+.2f}' for value in paired)}")
@@ -598,9 +612,18 @@ def run_gcc_supplement(runner: VerificationRunner) -> None:
run_transitions_and_invariants(runner, (GCC,))
def _tier_deadline(_signum: int, _frame: object) -> None:
raise VerificationFailure(
f"{ARGS.tier} tier exceeded its {ARGS.overall_timeout}s overall timeout")
def main() -> int:
runner = VerificationRunner(ARGS.tier)
started = time.monotonic()
if ARGS.overall_timeout <= 0:
raise VerificationFailure("--overall-timeout must be a positive number of seconds")
signal.signal(signal.SIGALRM, _tier_deadline)
signal.setitimer(signal.ITIMER_REAL, ARGS.overall_timeout)
try:
if not OWNER.is_file():
raise VerificationFailure(f"canonical owner input is missing: {OWNER}")
@@ -634,6 +657,8 @@ def main() -> int:
runner.save_summary("FAIL", str(error))
print(f"[{ARGS.tier}] DONE FAIL elapsed={elapsed:.1f}s error={error}", flush=True)
return 1
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
if __name__ == "__main__":