Editor and published-build diagnostics for packaging loose data. Provides console commands to check that required loose files (JSON, images, fonts, particles) are present in the published package or that editor-authored data matches what would be shipped.
using Sandbox;
using System;
using System.Linq;
using System.Text.Json;
namespace NZombies;
/// <summary>
/// `nz_publish_check` — does this build actually CARRY the loose data it reads?
///
/// ⛔ THIS EXISTS BECAUSE THE EDITOR CANNOT REPRODUCE THE FAILURE. `FileSystem.Mounted` in the
/// editor reads loose files straight off the project folder, so every manifest and config
/// resolves and everything looks fine. A PUBLISHED package carries only what s&box was told to
/// bundle — the `Resources` field in `nzombies_sbox.sbproj` — and a plain `.json` is not a
/// compiled asset. Get that field wrong and three systems come up empty in the published game
/// while working perfectly on the machine that built it.
///
/// ⛔ AND ALL THREE FAIL QUIETLY. On 2026-09-02 a published copy logged one line
/// (`maps/manifest.json unreadable`), showed an empty map browser, and said NOTHING about the
/// weapon library also being empty — `WeaponLibrary` skips a null manifest without a word. The
/// symptom read as "the map browser is broken" when the cause was packaging.
///
/// Run it in the PUBLISHED game, not the editor. In the editor it will nearly always pass.
/// </summary>
public static class PublishCheck
{
/// <summary>What the `Resources` field has to cover, and why. Kept beside the checks so the
/// two cannot drift.</summary>
static readonly (string Glob, string Why)[] Expected =
{
( "maps/*.json", "the shipped map list — MapLibrary" ),
( "weapons/*.json", "the weapon list and the stats panel — WeaponLibrary, WeaponClassStats" ),
// ⚠️ ONE FOLDER DEEPER, like everything the 2026-09-27 audit found (`Tools/publish_audit.py`): the glob above stops at
// `weapons/`, and the Prisma's parts list and the Kitbash guns' icons each sit in a folder of their own.
( "weapons/prisma/*.json", "the Prisma's parts list — SckPartsRig" ),
( "weapons/*/*.png", "the Kitbash guns' icons, named by their prefabs" ),
( "configs/*/*.json", "the original maps' spawns, buys and walls — MapConfig" ),
// ⛔ NOT `prefabs/weapons/*.prefab` (2026-10-03). It shipped every gun's text a second
// time, 1,070 files, for the stat bars and Chimera, and those read the COMPILED prefab
// now (`PrefabText`). The package's file list has to stay under 8 MiB.
// ⛔ LOOSE IMAGES ARE READ BY PATH AND SHIP LIKE ANY OTHER LOOSE FILE — THEY DO NOT
// RIDE THE ASSET GRAPH. A stylesheet's `url("ui/blood/blood_damage.png")` and a particle
// prefab's texture path are both resolved at RUNTIME, so nothing at compile time knows
// the file is needed. On 2026-09-09 that cost the muzzle flash, the tracers, the damage
// overlay, the ammo-type icons and the lobby background in one published build, all
// present in Assets/ and none of them in the package.
( "ui/*.png", "the default lobby background" ),
( "ui/*/*.png", "damage overlay, ammo-type icons, perk icons, per-map lobby backgrounds" ),
// ⚠️ DEPTH THREE IS NOT AN EDGE CASE. `ui/nz/cherry/shock_00.png` and every icon
// under `materials/nz/` sit one level deeper than the rest, and a glob set that stopped
// at two would have shipped MOST of the images — which is worse than shipping none,
// because a half-working HUD reads as a bug in the HUD.
( "ui/*/*/*.png", "Cherry's shock animation" ),
( "materials/nz/*/*.png", "powerup icons and map thumbnails" ),
( "materials/swb/*/*.png", "scope overlays" ),
( "materials/clues/*.png", "images a mapper puts on an easter-egg clue" ),
( "materials/clues/*/*.png", "the Easter egg keypad's glyphs and the Color Rings' ammo-mod symbols" ),
( "materials/models/weapons/spectra/*.png", "the Prisma's reload glow — PrismaReloadGlow loads them by path" ),
( "particles/*/*.png", "particle sprites" ),
( "particles/*/*/*.png", "muzzle flash frames" ),
( "particles/*/*/*.vtex_c", "the SWB muzzle flash and tracer flare textures" ),
// ⚠️ A FONT IS A LOOSE FILE TOO, and its licence has to travel with it (SIL OFL)
( "fonts/*.ttf", "Cinzel — the carved HUD's round numbers" ),
( "fonts/*.txt", "Cinzel's licence" ),
};
/// <summary>
/// Exact files, checked by name rather than by glob.
///
/// ⚠️ ONE PER FAILURE THAT ACTUALLY HAPPENED. A glob that looks right is not evidence;
/// this list is every file a published build has been SEEN to be missing, so a single run
/// answers "did the pattern work" for each of them by name.
/// </summary>
static readonly (string Path, string What)[] Files =
{
( "weapons/placement.json", "hand-aligned weapon and ADS positions" ),
( "weapons/tuning.json", "weapon stat overrides" ),
( "ui/lobby_bg.png", "the DEFAULT lobby background" ),
( "ui/maps/ttt_canyon_labs_d_bg.png", "Canyon Labs' own lobby background" ),
( "materials/nz/maps/ttt_canyon_labs_d.png", "the map browser's thumbnail" ),
( "materials/nz/powerups/dp.png", "the powerup icons" ),
( "ui/perks/dtap.png", "the perk HUD icons" ),
( "ui/nz/cherry/shock_00.png", "Cherry's shock animation" ),
( "materials/swb/scopes/bo1_wa2000.png", "the scope overlays" ),
( "ui/blood/blood_damage.png", "the player damage overlay" ),
( "ui/blood/blood_highlights.png", "the damage overlay's highlights" ),
( "ui/aat/deadwire.png", "the ammo-type icons" ),
( "particles/arc9/muzzle/flash_0.png", "the muzzle flash" ),
( "particles/swb/muzzle/flash.vtex_c", "the muzzle smoke texture" ),
( "particles/swb/muzzle/flare.vtex_c", "the tracer flare texture" ),
// the 2026-09-27 audit: one file from each folder it found no glob reaching
( "materials/clues/glyphs/glyph_1.png", "the Easter egg keypad's glyphs" ),
( "materials/clues/symbols/deadwire.png", "the Color Rings' ammo-mod symbols" ),
( "materials/clues/basalt_hex_tiles.png", "basalt's hex clue picture" ),
( "weapons/prisma/prisma_parts.json", "the Prisma's parts list" ),
( "weapons/cifosi_ac33/cifosi_ac33_icon.png", "the Kitbash guns' icons" ),
( "materials/models/weapons/spectra/spectra_bone_baset_r25.png", "the Prisma's reload glow" ),
( "fonts/Cinzel-Bold.ttf", "the carved HUD's round font" ),
};
[ConCmd( "nz_publish_check" )]
public static void Run()
{
Log.Info( "[nz-publish] ── loose data this build needs ──────────────────────" );
var ok = true;
ok &= Manifest( "maps/manifest.json", "shipped maps" );
ok &= Manifest( "weapons/manifest.json", "weapons" );
ok &= Configs();
ok &= Named();
PrefabSource();
// ⚠️ NOT INTO `ok`: its fix is a config, not the `Resources` field the failure message below names
MapGamemodes();
Log.Info( "[nz-publish] ─────────────────────────────────────────────────────" );
if ( ok )
{
Log.Info( "[nz-publish] all present. If something is still empty in game, it is not "
+ "packaging — look at the reader." );
return;
}
// ⚠️ THE FIX IS NAMED, NOT DESCRIBED. Somebody reading this in a published build is not
// in front of the project and cannot go looking for which field means what.
Log.Warning( "[nz-publish] SOMETHING IS MISSING FROM THE PACKAGE. Loose .json is not a "
+ "compiled asset, so it only ships if `Resources` in nzombies_sbox.sbproj lists it "
+ "(Project Settings > Assets writes the same field). It should read, one glob per "
+ "line, relative to Assets/:" );
foreach ( var (glob, why) in Expected )
Log.Warning( $"[nz-publish] {glob,-20} — {why}" );
Log.Warning( "[nz-publish] Set it, RE-PUBLISH, and run this again in the published copy. "
+ "Editing the field alone changes nothing that is already uploaded." );
}
/// <summary>
/// `nz_ship_data` — is what the EDITOR saved the same as what PUBLISHING would ship?
///
/// ⛔ A DIFFERENT QUESTION FROM `nz_publish_check`, AND THE OPPOSITE PLACE TO ASK IT. That
/// command asks "did the package carry the files", and only a published build can answer.
/// This one asks "are the files in Assets still the ones I authored", and only the EDITOR can
/// answer — a published copy has an empty `FileSystem.Data` to compare against.
///
/// ⛔ THE GAME SAVES WHERE PUBLISHING DOES NOT READ. `MapConfig.Save`, `WeaponPlacement` and
/// the tuning overrides all write through `FileSystem.Data`, a per-user folder. Publishing
/// packages `Assets/`. Nothing moves between the two on its own, so an edit made in the editor
/// is live here and absent from every published copy — and `MapConfig.Load` falls back to the
/// stale shipped file without raising anything.
///
/// ⚠️ THE COUNTS MATCH WHEN THE CONTENT DOES NOT. On 2026-09-15 all three live maps had
/// identical spawn, wallbuy and perk counts in both copies and different bytes, because the
/// edits were moves rather than additions. Comparing what the menus show is not a check.
///
/// ⚠️ `Tools/ship_data.py` IS THE FIX AND THE AUTHORITY. This compares text through the two
/// filesystems, which is enough to warn; the script hashes bytes and is what actually copies.
/// </summary>
[ConCmd( "nz_ship_data" )]
public static void ShipData()
{
if ( !Game.IsEditor )
{
Log.Warning( "[nz-ship] only meaningful in the EDITOR — a published build has no "
+ "FileSystem.Data to compare the shipped copies against." );
return;
}
Log.Info( "[nz-ship] ── editor data vs what would be published ─────────────" );
var stale = 0;
// The two loose files that are authored in game and shipped under a different name.
stale += Compare( "weapon_placement.json", "weapons/placement.json",
"hand-aligned weapon and ADS positions" ) ? 0 : 1;
stale += Compare( "weapon_tuning.json", "weapons/tuning.json",
"weapon stat overrides" ) ? 0 : 1;
// ⚠️ WALKED FROM DATA, NOT FROM MOUNTED. A config saved for a map that has never had a
// shipped copy exists only in Data, and walking the shipped side would never see it —
// which is the case that loses a whole map's setup rather than part of one.
const string root = "configs";
if ( FileSystem.Data.DirectoryExists( root ) )
{
foreach ( var map in FileSystem.Data.FindDirectory( root ) )
{
foreach ( var f in FileSystem.Data.FindFile( $"{root}/{map}", "*.json" ) )
{
var path = $"{root}/{map}/{f}";
if ( !Compare( path, path, "map config" ) ) stale++;
}
}
}
Log.Info( "[nz-ship] ──────────────────────────────────────────────────────" );
if ( stale == 0 )
{
Log.Info( "[nz-ship] everything you have authored is already in Assets/. Safe to publish." );
return;
}
// ⚠️ THE FIX IS THE COMMAND, SPELLED OUT. Describing it costs a lookup at the exact
// moment somebody is trying to ship.
Log.Warning( $"[nz-ship] {stale} file(s) would publish STALE or not at all. Run this in the "
+ "working folder, then publish:" );
Log.Warning( "[nz-ship] py Tools/ship_data.py --apply" );
Log.Warning( "[nz-ship] Or leave `py Tools/ship_data.py --watch` running while you edit, "
+ "and Assets/ stays current on its own." );
}
/// <summary>One file, Data against Mounted. True when they agree or Data has nothing to say.</summary>
static bool Compare( string dataPath, string shippedPath, string what )
{
if ( !FileSystem.Data.FileExists( dataPath ) )
return true; // never authored here — nothing to be stale
string mine = null, theirs = null;
try { mine = FileSystem.Data.ReadAllText( dataPath ); } catch ( Exception ) { }
try { theirs = FileSystem.Mounted.ReadAllText( shippedPath ); } catch ( Exception ) { }
if ( string.IsNullOrWhiteSpace( theirs ) )
{
Log.Warning( $"[nz-ship] {shippedPath,-38} NOT IN Assets/ — {what} exists only on this "
+ "machine and will not ship" );
return false;
}
if ( string.Equals( mine?.Trim(), theirs.Trim(), StringComparison.Ordinal ) )
{
Log.Info( $"[nz-ship] {shippedPath,-38} ok — {what}" );
return true;
}
Log.Warning( $"[nz-ship] {shippedPath,-38} STALE — {what}; the copy that would publish is "
+ "older than the one you edited" );
return false;
}
/// <summary>Every file in <see cref="Files"/>, present or not.</summary>
static bool Named()
{
var all = true;
foreach ( var (path, what) in Files )
{
if ( FileSystem.Mounted.FileExists( path ) )
{
Log.Info( $"[nz-publish] {path,-38} ok — {what}" );
continue;
}
all = false;
Log.Warning( $"[nz-publish] {path,-38} MISSING — {what} will not appear" );
}
return all;
}
/// <summary>Read a manifest, parse it, and say how many entries came out.</summary>
static bool Manifest( string path, string what )
{
string json;
try { json = FileSystem.Mounted.ReadAllText( path ); }
catch ( Exception e )
{
Log.Warning( $"[nz-publish] {path,-24} THREW — {e.Message}" );
return false;
}
// ⚠️ NULL AND EMPTY ARE THE SAME ANSWER HERE and both mean "not in the package".
// `ReadAllText` on a missing mounted file returns null rather than throwing, which is
// why the original failure surfaced as "Value cannot be null (Parameter 'json')" from
// the JSON parser two frames later instead of as a missing file.
if ( string.IsNullOrWhiteSpace( json ) )
{
Log.Warning( $"[nz-publish] {path,-24} MISSING — not in this build" );
return false;
}
try
{
using var doc = JsonDocument.Parse( json );
var n = doc.RootElement.EnumerateObject().Count( e => !e.Name.StartsWith( '_' ) );
Log.Info( $"[nz-publish] {path,-24} ok — {n} {what}" );
return true;
}
catch ( Exception e )
{
Log.Warning( $"[nz-publish] {path,-24} PRESENT BUT UNPARSEABLE — {e.Message}" );
return false;
}
}
/// <summary>
/// The shipped map configs — the ones that decide whether a map has spawns at all.
///
/// ⚠️ ENUMERATION AND READING ARE TESTED SEPARATELY. `MapConfig.ListFor` walks the directory
/// (`DirectoryExists` + `FindFile`) while `Load` reads one exact path, and the codebase had
/// never used enumeration on `Mounted` before shipping this. A build where the file is
/// present but the directory does not enumerate loads fine by name and shows an empty menu.
/// </summary>
static bool Configs()
{
const string root = "configs";
if ( !FileSystem.Mounted.DirectoryExists( root ) )
{
Log.Warning( $"[nz-publish] {root + "/",-24} MISSING — no shipped map configs, so every "
+ "original map loads with no spawns, buys or walls" );
return false;
}
var maps = FileSystem.Mounted.FindDirectory( root ).ToList();
if ( maps.Count == 0 )
{
Log.Warning( $"[nz-publish] {root + "/",-24} PRESENT BUT EMPTY — the directory shipped "
+ "and its contents did not" );
return false;
}
var files = 0;
foreach ( var map in maps )
{
var names = FileSystem.Mounted.FindFile( $"{root}/{map}", "*.json" ).ToList();
files += names.Count;
Log.Info( $"[nz-publish] {map}: {( names.Count == 0 ? "no configs" : string.Join( ", ", names ) )}" );
}
if ( files == 0 )
{
Log.Warning( $"[nz-publish] {root + "/",-24} directories shipped, files did not" );
return false;
}
Log.Info( $"[nz-publish] {root + "/*/*.json",-24} ok — {files} config(s) across {maps.Count} map(s)" );
return true;
}
/// <summary>
/// Does every map in the browser have a gamemode? The published lobby plays only the configs that ship (`Gamemodes`,
/// 2026-10-05), so a map with none cannot be readied at all.
///
/// ⚠️ IN THE EDITOR TOO, AND THAT IS WHERE IT IS WORTH MOST: there it reads Assets, which is what a publish would carry,
/// so it names the maps to `ship_data.py --apply` before publishing rather than after.
/// </summary>
static void MapGamemodes()
{
MapLibrary.Reload();
// ⛔ THE MAPS PLAYERS GET (2026-10-05): a published copy offers only those marked ready (`MapLibrary.Offered`), so
// those are the ones that must ship a gamemode. Said in the editor too, where it is the list a publish would carry.
var ready = MapLibrary.Originals.Where( m => m.Ready ).ToList();
Log.Info( $"[nz-publish] {"maps for players",-24} {ready.Count} of {MapLibrary.Originals.Count} marked ready: "
+ (ready.Count == 0 ? "NONE — a published copy would offer nothing" : string.Join( ", ", ready.Select( m => m.Name ) )) );
var missing = ready
.Select( m => NZMap.KeyFor( m.MapName ) )
.Where( key => Gamemodes.For( key ).Count == 0 )
.ToList();
if ( missing.Count == 0 )
{
Log.Info( $"[nz-publish] {"gamemodes",-24} ok — every ready map ships at least one" );
return;
}
Log.Warning( $"[nz-publish] {"gamemodes",-24} {missing.Count} map(s) ship NO gamemode, so the published lobby "
+ $"cannot start them: {string.Join( ", ", missing )}. A config in Assets/configs/<map>/ is one — "
+ "ship_data.py --apply copies the editor's" );
}
/// <summary>
/// A weapon prefab's JSON, which two panels read without spawning the gun (`PrefabText`).
///
/// ⚠️ REPORTED, NOT FAILED ON. `WeaponClassStats` and `ChimeraPool` both read it through
/// `PrefabText` and both return null when it is not there — the stats panel and the
/// chimera donor lookup degrade rather than break. So this is worth KNOWING about a published
/// build without being a reason to call the package broken.
/// </summary>
static void PrefabSource()
{
// ⛔ IT ASKS THE MANIFEST, NOT THE FOLDER. `WeaponClassStats` uses the manifest KEY
// as the path (`prefabs/weapons/nz_357.prefab`), so probing some other directory would
// test a path nothing reads — the first version of this looked under `weapons/` and
// reported "none found" about a folder that has never held a prefab.
string manifest;
try { manifest = FileSystem.Mounted.ReadAllText( "weapons/manifest.json" ); }
catch ( Exception ) { manifest = null; }
if ( string.IsNullOrWhiteSpace( manifest ) )
{
Log.Info( "[nz-publish] prefab source not checked — no weapon manifest to read a path from" );
return;
}
string path = null;
try
{
using var doc = JsonDocument.Parse( manifest );
path = doc.RootElement.EnumerateObject()
.Select( e => e.Name )
.FirstOrDefault( n => !n.StartsWith( '_' ) );
}
catch ( Exception ) { }
if ( path is null )
{
Log.Info( "[nz-publish] prefab source not checked — the manifest named no prefabs" );
return;
}
var text = PrefabText.Read( path );
if ( string.IsNullOrWhiteSpace( text ) )
{
Log.Warning( $"[nz-publish] {"prefab source",-24} MISSING — '{path}' gave no JSON. "
+ "The weapon stat bars and the chimera donor list will be blank (both fail soft, "
+ "so nothing else breaks)." );
return;
}
Log.Info( $"[nz-publish] {"prefab source",-24} ok — '{path}' readable" );
}
}