Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ Run it from the repository with `dotnet run --project src/SimplePZMapmaker.Cli -

| Command | What it does |
|---|---|
| `fetch --bounds <south,west,north,east> --out osm.json` | Downloads OpenStreetMap data with the editor's own Overpass client (tiled requests, retries, provider fallback, seven-day cache). |
| `build <scene.json>` | Imports GeoJSON or Overpass JSON into a geographic world, promotes seam-crossing buildings and fences to the world, runs the seam check and writes `.pzworld.json`; `--export mod.zip` also writes the Build 42 mod. |
| `validate <file>` | Validates a `.pzmap.json` or `.pzworld.json`. With `--source` it runs the seam check against the original data. |
| `export <file> --out mod.zip` | Writes the Build 42 mod of a map or world. |
| `tiles <tileset.json> --out tiles.zip` | Builds a tile mod (`.pack` + `.tiles`) from PNG frames. |
| `probe <mod.zip> --game <path>` | Loads the exported lots with the installed game's own classes (headers, chunks, rooms, every tile sprite; stairs with `--expect-stairs`). Needs the repository's `scripts/` folder and Java from the game. |
| `server-check <mod.zip> --game <path>` | Starts the installed Build 42 dedicated server (`zombie.network.GameServer`, `-nosteam`) in a temporary cache folder with the mod and a test-only Lua probe. The probe asks the server's own metagrid for buildings at room tiles taken from the archive's authoring source, plus one empty control tile; the server is then stopped with `quit`. Your `Zomboid` folder is not touched. |

Common options:

- `--game <path>` points to the Build 42 installation. `build`, `validate` and `export` then check every tile name the exporter would write against the game's tile definitions and validate resources; `tiles` reads properties for `copyFrom`.
- `--strict` makes `build` fail when the seam check finds a lost or duplicated source feature.
- `--save-workspace` (`build`) also saves the world as an editor section workspace under `work/world-recovery` of the workspace (`PZMAP_WORKSPACE`, or the current folder). Open it under **World → Section workspaces**; this works for worlds larger than the 128 MB limit of a single `.pzworld.json`.
- `--report report.json` saves the seam check.
- Mod options: `--mod-id`, `--map-folder` (the server `Map=` value, defaults to the mod id), `--mod-name` (title in the mod list and map selector), `--common-maps` (puts `media/maps` under `common/` instead of `42/`), `--install` (extracts into `Zomboid/mods`, or `--mods-dir`). An install replaces only a folder that SimplePZMapmaker created earlier (it carries a `.simplepzmapmaker` marker).

Expand Down
2 changes: 2 additions & 0 deletions docs/TRANSFER-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ Pipeline-generated verge wrecks are emitted as `pz:zone=Vehicle` polygons with `
Implementation note: authoritative `WorldProject.Fences` survive isolated section reads, editing validation and streamed export with gate/post phase preserved across seams. The world importers (editor OSM import and `simplepzmap build`) promote every imported fence line whose edges reach two or more sections into the world layer once, merge the gates found near each section, and keep only the owning section's copy of a line that merely touches a neighbour. The seam check then verifies zero lost or duplicated buildings and fences.

`PilnaVillage` is built from the section scenes of real Pilna data: 21 sections of 1024 tiles under the rotated reference (the earlier, pre-rotated frame had 13). It is accepted when all of the following hold:

*Status 2026-10-17:* built from the OpenStreetMap extract with `examples/Pilna/village-scene.json` (26 sections, as empty sections with roads are kept). Every check below passes except fence continuity, which the extract cannot exercise because it has no barrier lines; see [examples/Pilna](../examples/Pilna/README.md#pilnavillage-world). The field-survey scenes are not in this repository.
- `WorldAssembler.Validate` passes;
- the seam check reports zero lost or duplicated source features;
- every fence that crosses a section edge is continuous in the exported cells;
Expand Down
15 changes: 15 additions & 0 deletions examples/Pilna/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,18 @@ node scripts/build-pilna.mjs
The script uses the saved source and needs no network. For fresh geography, search for Pilna and select full bounds in the UI. Save before updating; the manual-edit preservation toggle protects edited buildings.

Checks accepted 64 native cells, 65,536 chunks and 7,345 rooms, including installed-game loader reads and 204 recognized stair sprites. Character traversal, roof/furniture appearance, loot and multiplayer are unverified. A railway layer is not implemented.

## PilnaVillage world

`village-scene.json` builds the whole settlement as one geographic world at 1 m per tile: 1,024-tile sections under a reference rotated −11.22° to follow the streets. From the repository root:

```powershell
dotnet run --project src/SimplePZMapmaker.Cli -- build examples/Pilna/village-scene.json --out work/PilnaVillage.pzworld.json --export work/PilnaVillage.zip --strict --save-workspace --game "<Build 42 folder>"
dotnet run --project src/SimplePZMapmaker.Cli -- server-check work/PilnaVillage.zip --game "<Build 42 folder>"
```

Result on 2026-10-17: 26 sections, 1,640 section buildings and 61 shared buildings across seams; the seam check reports 0 lost and 0 duplicated outlines (88 kept as evidence: 42 water or road, 27 outlines over 64 tiles, 18 overlaps, 1 crossing outline rejected at world level). All 2,471 exported tile names exist in the game. The structural verifier, the installed-class probe (416 cells, 425,984 chunks, 9,565 rooms, 249 stair tiles) and the dedicated server check pass: the server loads the mod, its metagrid holds the sampled Pilna buildings with their rooms, and it stops cleanly. The build takes about five minutes; the world JSON is about 200 MB, so open it through the saved section workspace.

The OpenStreetMap source contains almost no fences, gates or sheds, so the village has none. Field-survey fences and outbuildings can be added as GeoJSON `sources` with the transfer-contract tags.

The server logs that the spawn points stand outside buildings (`initSpawnBuildings: no room or building`): the exporter places spawns on open dry ground, while vanilla maps spawn players inside houses. No client joined the server, so spawning itself was not tested.
17 changes: 17 additions & 0 deletions examples/Pilna/village-scene.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"format": "SimplePZScene",
"version": 1,
"name": "PilnaVillage",
"source": "osm-source.json",
"reference": {
"bounds": { "south": 55.5363, "north": 55.5781, "west": 45.8833, "east": 45.9422 },
"metresPerTile": 1,
"originCellX": 100,
"originCellY": 100,
"rotationDegrees": -11.22
},
"sectionSize": 1024,
"seed": 42,
"skipEmptySections": true,
"export": { "modId": "PilnaVillage", "name": "Pilna village" }
}
25 changes: 23 additions & 2 deletions src/SimplePZMapmaker.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,25 @@
using SimplePZMapmaker.Web.Services;

const string Usage="""
simplepzmap fetch --bounds <south,west,north,east> --out osm.json
simplepzmap build <scene.json> [--out world.pzworld.json] [--export mod.zip] [--report report.json] [--strict] [--game <path>] [mod options]
simplepzmap validate <map.pzmap.json|world.pzworld.json> [--game <path>] [--source data.geojson] [--strict]
simplepzmap export <map.pzmap.json|world.pzworld.json> --out mod.zip [--game <path>] [mod options]
simplepzmap probe <mod.zip> --game <path> [--expect-stairs]
simplepzmap server-check <mod.zip> --game <path> [--timeout-minutes 10]
simplepzmap tiles <tileset.json> --out tiles.zip [--game <path>] [--install] [--mods-dir <dir>]

mod options: --mod-id <id> --map-folder <name> --mod-name "<title>" --common-maps --install [--mods-dir <dir>]
--game checks every tile name against the installed Build 42 tile definitions and validates resources.
--strict fails the command when the seam check finds lost or duplicated source features.
--save-workspace (build) also saves the world as an editor section workspace (open it under World > Section workspaces).
""";
try{return Run(args);}
catch(ArgumentException e){Console.Error.WriteLine("error: "+e.Message);return 1;}
catch(Exception e) when(e is IOException or UnauthorizedAccessException or JsonException){Console.Error.WriteLine("error: "+e.Message);return 1;}
catch(Exception e) when(e is IOException or UnauthorizedAccessException or JsonException or HttpRequestException or TimeoutException){Console.Error.WriteLine("error: "+e.Message);return 1;}

static int Run(string[] args){
if(args.Length>0&&args[0]=="fetch")return Fetch(Options(args[1..]));
if(args.Length<2||args[0] is "-h" or "--help"){Console.WriteLine(Usage);return args.Length==0||args[0] is "-h" or "--help"?0:2;}
var command=args[0];var input=Path.GetFullPath(args[1]);var options=Options(args[2..]);
string? Option(string name)=>options.GetValueOrDefault(name);bool Flag(string name)=>options.ContainsKey(name);
Expand All @@ -36,6 +40,7 @@ static int Run(string[] args){
var failed=Report(build.Report,Option("--report"));if(assets is not null)failed|=CheckTiles(world,assets);
var output=Path.GetFullPath(Option("--out")??Path.ChangeExtension(input,null)+".pzworld.json");using(var file=File.Create(output))JsonSerializer.Serialize(file,world,NativeMapExporter.JsonOptions);Console.WriteLine("wrote "+output);
if(Option("--export") is {} target)Export(s=>NativeWorldExporter.ExportToStream(world,s),Path.GetFullPath(target),Mod(scene.Export),Option("--mods-dir"));
if(Flag("--save-workspace")){using var store=new WorldStore(Path.Combine(workspace,"work","world-recovery"));store.SaveAsync(world.Id,world).GetAwaiter().GetResult();Console.WriteLine($"saved section workspace {world.Id} under {Path.Combine(workspace,"work","world-recovery")}; open it in the editor under World > Section workspaces");}
return failed&&Flag("--strict")?1:0;}
case "validate":{
var (map,world)=Load(input);var failed=false;
Expand All @@ -52,12 +57,28 @@ static int Run(string[] args){
using(var file=File.Create(target))TilesetBuilder.Write(mod,file);Console.WriteLine($"wrote {target}: {mod.Sprites.Count} tiles {mod.Tileset}_0..{mod.Sprites.Count-1}, tiledef {mod.FileNumber}");
if(Flag("--install")){using var archive=File.OpenRead(target);Console.WriteLine("installed "+NativeModPackage.Install(archive,Option("--mods-dir")??NativeModPackage.DefaultModsDirectory()));}
return 0;}
case "server-check":{
var minutes=int.TryParse(Option("--timeout-minutes"),out var m)?m:10;var result=ServerCheck.Run(input,Option("--game")??throw new ArgumentException("server-check needs --game <Build 42 installation>."),TimeSpan.FromMinutes(minutes),Console.WriteLine);
foreach(var (point,building,room) in result.Probes)Console.WriteLine($"server metagrid {point.X},{point.Y} {point.Name}: building={building.ToString().ToLowerInvariant()} room={room??"none"}{(building==point.ExpectBuilding?"":" UNEXPECTED")}");
Console.WriteLine($"server started: {result.Started}; stopped cleanly: {result.Stopped}; {(result.Passed?"server check: passed":"server check: FAILED")}");
if(!result.Passed)File.WriteAllText(Path.ChangeExtension(input,null)+".server.log",result.Log);
return result.Passed?0:1;}
case "probe":return Probe(input,Option("--game")??throw new ArgumentException("probe needs --game <Build 42 installation>."),Flag("--expect-stairs"));
default:Console.Error.WriteLine(Usage);return 2;
}
}
/// <summary>Downloads OpenStreetMap data with the editor's own client: tiled Overpass requests, retries, provider fallback and a seven-day cache.</summary>
static int Fetch(Dictionary<string,string> options){
var parts=(options.GetValueOrDefault("--bounds")??throw new ArgumentException("fetch needs --bounds south,west,north,east.")).Split(',').Select(v=>double.Parse(v,System.Globalization.CultureInfo.InvariantCulture)).ToArray();
if(parts.Length!=4)throw new ArgumentException("--bounds takes four numbers: south,west,north,east.");var target=Path.GetFullPath(options.GetValueOrDefault("--out")??throw new ArgumentException("fetch needs --out osm.json."));
var workspace=Environment.GetEnvironmentVariable("PZMAP_WORKSPACE")??Directory.GetCurrentDirectory();using var client=new OsmClient(Path.Combine(workspace,"work","geodata-cache"));
var download=client.FetchTiledDetailed(new GeoBounds{South=parts[0],West=parts[1],North=parts[2],East=parts[3]},CancellationToken.None,null,64_000_000).GetAwaiter().GetResult();
File.WriteAllText(target,download.Data);foreach(var w in download.Warnings)Console.WriteLine("note: "+w);
Console.WriteLine($"wrote {target}: {download.Source}, data date {download.DataTimestamp??"not specified"}, {download.Requests} requests{(download.FromCache?" (cache)":"")}. Map data (c) OpenStreetMap contributors, ODbL 1.0.");
return 0;
}
static Dictionary<string,string> Options(string[] args){
var flags=new HashSet<string>{"--common-maps","--install","--strict","--expect-stairs"};var result=new Dictionary<string,string>(StringComparer.Ordinal);
var flags=new HashSet<string>{"--common-maps","--install","--strict","--expect-stairs","--save-workspace"};var result=new Dictionary<string,string>(StringComparer.Ordinal);
for(var i=0;i<args.Length;i++){if(!args[i].StartsWith("--",StringComparison.Ordinal))throw new ArgumentException("Unexpected argument: "+args[i]);if(flags.Contains(args[i])){result[args[i]]="true";continue;}if(i+1>=args.Length)throw new ArgumentException(args[i]+" needs a value.");result[args[i]]=args[++i];}
return result;
}
Expand Down
Loading