Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
47cf9a9
feat: custom items, Nadeo records, game rotation order, clip caps
zenril Sep 14, 2026
b026ac4
feat: TM Exchange drawer tab with map card and toggleable driving lines
zenril Sep 14, 2026
e156f49
fix(meshdump): repair Mesh Modeler items GBX.NET cannot read
zenril Sep 14, 2026
3e6ef61
feat(layers): block tree with group and per-placement visibility
zenril Sep 16, 2026
6d9cf63
feat(layers): find-selected buttons, list selection, deeper third level
zenril Sep 16, 2026
b19f7e2
fix(paint): map palette and per-material colour tables from the game …
zenril Sep 16, 2026
94038c1
feat: save the track as a real Map.Gbx into the game's Maps folder
zenril Sep 18, 2026
f9d7099
feat(meshdump): lightmap extract/inject and a research log for baking…
zenril Sep 18, 2026
00e035b
docs: log the sun and moon placement idea in the lightmap research notes
zenril Sep 18, 2026
1224767
docs: lightmap experiment passed in game; log sky options; meshdump t…
zenril Sep 18, 2026
1bdada3
feat(meshdump): atmosphere (mood + fog clip), mediainfo; sky research…
zenril Sep 19, 2026
d7dd565
feat: sky mod builder (own BC6H encoder) and map mod references
zenril Sep 19, 2026
a63f4d9
docs: sky mod experiment passed in game (sky, clouds, mood settings);…
zenril Sep 19, 2026
830a75f
docs: car lighting follows the mod's sun; static geometry keeps the bake
zenril Sep 19, 2026
d6c58ad
feat(meshdump): map daytime option; sun position experiment maps and …
zenril Sep 19, 2026
0dea708
feat(sky_mod): dark chart so the game's sun stays readable; sun sweep…
zenril Sep 19, 2026
62b5c91
feat(sky_mod): numbered grid chart with bitmap digits; sun sweep thir…
zenril Sep 19, 2026
f7107ee
docs: sun grid readings (mirrored half-sky, seam follows the sun, 0.5…
zenril Sep 19, 2026
06b5e28
feat(sky_mod): self-describing grid cells (mirror marker, map id); fi…
zenril Sep 19, 2026
9a5b546
feat(sky_mod): place the sun by azimuth/altitude; Sun5 readings and t…
zenril Sep 19, 2026
2d74fcc
feat(core): sun model (direction from DayTime01/Latitude and its inve…
zenril Sep 19, 2026
2283737
feat: sun tool and "Sky & light" page (custom sun, moon, fog, sky ima…
zenril Sep 19, 2026
6e5dc5c
feat: share a map's sun/sky mod — download or reveal the zip, upload,…
zenril Sep 19, 2026
9babddd
feat(share): "Only on this machine" is remembered per mod; Share… but…
zenril Sep 19, 2026
1b2a315
feat: driving lines in the layer list with the checkpoints they take,…
zenril Sep 19, 2026
1dc55c5
perf(ghostPath): waypoint changes rebuild only the number tags, never…
zenril Sep 19, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ tools/meshdump/missing_files.txt
tools/meshdump/extract_list.txt
.trackedit.local.json
sheets/
__pycache__/
257 changes: 250 additions & 7 deletions README.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions docs/NOTES-block-import-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,58 @@ turns for wall panels and skirt trimming of ground clips.
- The pillar clips under elevated snow roads render as legs; the icons
don't show them, and we haven't confirmed in-game behaviour.

## 5b. Custom items embedded in maps (icecomp, TMX #84337)

"Big empty spots where custom geometry is missing" turned out to be two
rules we hadn't read, not missing data:

- **Items rotate about a pivot.** The anchored object stores
`PivotPosition`; custom items made in the Mesh Modeler typically pivot at
their centre (`[-16, -6, -16]` for a 32 m half-banked slab). We rotated
about the model origin, so every flipped (pitch 180°) or turned piece
landed a cell away and 6–10 m low — the "missing" pieces were the dark
slabs stranded next to the track. Fix: carry `pivotPos` through the dump,
the placement and the renderer (`pos + R * pivot`). The ghost line was
the reference: it drives exactly over the pieces once they are placed
right.
- **Custom meshes name materials by path.** Crystal exports say
`Stadium\Media\Modifier\PlatformIce\PlatformTech`; fbx-style items
bind a `CPlugMaterialUserInst` whose `Link` is that path. The library is
keyed by the short names the block extraction produces
(`PlatformIce.PlatformTech`), so nothing matched and every custom item
drew flat grey. One canonicalisation, applied at export (and as a
fallback on the client for older libraries), fixed all of them.
- The user's `.trackedit.local.json` pointed TMX import at an older
external `gbxdump` that has no `pivotPos`. The bundled converter now runs
first; the override is a fallback.

- **Rotation order.** The stored yaw/pitch/roll is applied yaw → roll →
pitch (three.js "YZX"), not yaw → pitch → roll. Measured on TMX #84440
("you just got COLLEGED!"): scoring how many record-ghost samples lie
within 2.5 m of each tilted free block's mesh gave 569 for YZX against
319 for YXZ, and YZX won or tied on every block that has both pitch and
roll. Blocks with only one of the two never showed the bug, which is why
it survived so long. `core/math.ts` now owns the order
(`GAME_EULER_ORDER`, `quatFromGameRot`, `gameRotFromQuat`); the renderer,
the transform operator and the exporter all go through it.

- **Clip caps are per side, not per block.** Three cruise-control
platforms in a row (TMX #84442) each showed their end "turbines" and the
record line drove straight through them. The block's unit carries a clip
on every face (`PlatformFCSmall` north/south, `PlatformSpecialFCRight` /
`...Left` east/west, `PlatformBaseFCB` below), all in clip group
`PlatformFCSmallClips`; the map lists no clip blocks at all. So the game
shows a clip only on an open side and hides it when the neighbour's facing
clip shares the group. We had baked every clip into the block mesh. Now
each clip is its own OBJ group and the renderer hides joined ones from
neighbour lookups (`render/clipAdjacency.ts`, pure, tested). This is the
same mechanism for every block family: platform trims, deco walls, base
undersides joining the top-cap group of the block below.

Still open: tracko's `gbxbuild` writes `PivotPosition = 0` for every item,
so an exported map would put custom items back in the wrong place in-game
until it reads the `pivotPos` the editor now emits.

## 6. Lessons

- Get a reference before judging. The icons settled arguments in minutes
Expand Down
543 changes: 543 additions & 0 deletions docs/NOTES-lightmap-baking.md

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/core/atmosphere.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { clockOf, directionFrom, headingAltitude, isDownloadUrl, lightDirection, sunFromHeading } from "./atmosphere";
import { sunDirection } from "./sun";

describe("compass", () => {
it("reads headings clockwise from the game's North (+Z), East being -X", () => {
expect(headingAltitude([0, 0, 1]).heading).toBeCloseTo(0, 6);
expect(headingAltitude([-1, 0, 0]).heading).toBeCloseTo(90, 6);
expect(headingAltitude([0, 0, -1]).heading).toBeCloseTo(180, 6);
expect(headingAltitude([1, 1, 0]).altitude).toBeCloseTo(45, 6);
});

it("round-trips heading/height through the sun settings", () => {
for (const [heading, altitude] of [[120, 30], [300, 60], [180, 75], [90, 20], [10, 5]]) {
const back = headingAltitude(sunDirection(sunFromHeading(heading, altitude)));
expect(back.heading).toBeCloseTo(heading, 5);
expect(back.altitude).toBeCloseTo(altitude, 5);
}
});

it("keeps the sun above the horizon", () => {
const low = headingAltitude(sunDirection(sunFromHeading(200, -30)));
expect(low.altitude).toBeCloseTo(1, 5);
expect(directionFrom(200, 1)[1]).toBeGreaterThan(0);
});
});

describe("lightDirection", () => {
it("uses the mood's own sun until a custom one is set", () => {
const day = headingAltitude(lightDirection("Day", null));
expect(day.heading).toBeCloseTo(90 + 65.3, 0);
expect(day.altitude).toBeCloseTo(42.3, 0);
const custom = lightDirection("Day", { dayTime01: 0.625, latitude: 0, color: null, intensity: 1, moonColor: null, moonIntensity: 1 });
expect(custom[1]).toBeCloseTo(1, 6);
});
});

describe("isDownloadUrl", () => {
it("takes absolute web links only", () => {
expect(isDownloadUrl("https://example.com/mods/TrackeditSun_ab_12.zip")).toBe(true);
expect(isDownloadUrl(" http://files.example.org/x.zip ")).toBe(true);
expect(isDownloadUrl("C:\\mods\\x.zip")).toBe(false);
expect(isDownloadUrl("ftp://example.com/x.zip")).toBe(false);
expect(isDownloadUrl("https://localhost/x.zip")).toBe(false);
expect(isDownloadUrl("")).toBe(false);
});
});

describe("clockOf", () => {
it("maps the daylight arc onto 06:00..21:00", () => {
expect(clockOf(0.5)).toBe("06:00");
expect(clockOf(0.6)).toBe("12:00");
expect(clockOf(0.75)).toBe("21:00");
});
});
141 changes: 141 additions & 0 deletions src/core/atmosphere.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* A map's custom sky and light: where the sun is and what colour it is, fog,
* and a sky image. Pure data — the renderer previews it, and saving to the
* game writes it as a mood mod plus a MediaTracker fog clip
* (tools/gameBridge.ts). Everything is optional: null means "the mood's own".
*/
import { SUNRISE01, SUNSET01, solveSun, sunDirection, type SunSettings } from "./sun";
import type { Vec3 } from "./math";

export interface CustomSun extends SunSettings {
/** #rrggbb; null keeps the mood's own sun colour. */
color: string | null;
/** Brightness multiplier on the mood's own sun. */
intensity: number;
/** The moon sits opposite the sun. null keeps the mood's own colour. */
moonColor: string | null;
moonIntensity: number;
}

export interface CustomFog {
/** #rrggbb */
color: string;
/** 0..1: how strongly the fog colours distant geometry. */
intensity: number;
/** 0..1: how much of the fog colour tints the sky itself. */
skyIntensity: number;
/** Metres until the fog is at full strength. */
distance: number;
/** 0..1 */
cloudsOpacity: number;
}

export interface CustomSky {
/** File name under the dev server's sky store (maps/sky). */
image: string;
/** HDR scale applied to the image. */
exposure: number;
/** Keep the mood's cloud layer over the image, or clear it. */
clouds: "keep" | "clear";
}

/**
* Where the map's sun/sky mod can be downloaded from, once its author has
* uploaded it somewhere. Tied to the exact zip (its content-hashed name): a
* changed look is a new file and has to be uploaded again.
*/
export interface HostedMod {
name: string;
url: string;
}

export interface Atmosphere {
sun: CustomSun | null;
fog: CustomFog | null;
sky: CustomSky | null;
hosted?: HostedMod | null;
}

export const EMPTY_ATMOSPHERE: Atmosphere = { sun: null, fog: null, sky: null, hosted: null };

/** A link the game could download from: absolute http(s). */
export function isDownloadUrl(text: string): boolean {
try {
const url = new URL(text.trim());
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname.includes(".");
} catch {
return false;
}
}

export const DEFAULT_FOG: CustomFog = { color: "#c8d4e0", intensity: 0.5, skyIntensity: 0.5, distance: 6000, cloudsOpacity: 1 };

/** The game's own sun per mood (Media/Moods/<Mood>/Mood.MoodSetting.xml). Night is lit by the moon. */
export const MOOD_SUNS: Record<string, SunSettings> = {
Sunrise: { dayTime01: 0.52, latitude: 45 },
Day: { dayTime01: 0.6, latitude: 45 },
Sunset: { dayTime01: 0.73, latitude: 45 },
Night: { dayTime01: 0.15, latitude: 45 },
};

export function isDaylight(s: SunSettings): boolean {
return s.dayTime01 >= SUNRISE01 && s.dayTime01 <= SUNSET01;
}

export function customSunFrom(settings: SunSettings, base?: CustomSun | null): CustomSun {
return {
color: base?.color ?? null,
intensity: base?.intensity ?? 1,
moonColor: base?.moonColor ?? null,
moonIntensity: base?.moonIntensity ?? 1,
dayTime01: settings.dayTime01,
latitude: settings.latitude,
};
}

/** Lowest sun the tool allows: at 0 the settings stop being solvable. */
export const MIN_SUN_ALTITUDE = 1;

/**
* Compass reading of a direction: heading in degrees clockwise from the
* game's North (+Z; East is -X) and height above the horizon.
*/
export function headingAltitude(dir: Vec3): { heading: number; altitude: number } {
const len = Math.hypot(dir[0], dir[1], dir[2]) || 1;
const heading = (Math.atan2(-dir[0], dir[2]) * 180) / Math.PI;
return { heading: (heading + 360) % 360, altitude: (Math.asin(dir[1] / len) * 180) / Math.PI };
}

export function directionFrom(heading: number, altitude: number): Vec3 {
const h = (heading * Math.PI) / 180, a = (altitude * Math.PI) / 180;
return [-Math.sin(h) * Math.cos(a), Math.sin(a), Math.cos(h) * Math.cos(a)];
}

/** Sun settings for a compass heading/height (height clamped above the horizon). */
export function sunFromHeading(heading: number, altitude: number): SunSettings {
const alt = Math.min(90, Math.max(MIN_SUN_ALTITUDE, altitude));
return solveSun(directionFrom(heading, alt))!;
}

/** Where the light comes from for a mood, custom sun or not. Night's own light is not modelled: it gets the Day arc's mirror. */
export function lightDirection(mood: string, sun: CustomSun | null): Vec3 {
if (sun) return sunDirection(sun);
const own = MOOD_SUNS[mood] ?? MOOD_SUNS.Day;
return isDaylight(own) ? sunDirection(own) : [0.3, 1, 0.5];
}

/** The map's-clock reading of DayTime01 (06:00 sunrise .. 21:00 sunset), as "HH:MM". */
export function clockOf(dayTime01: number): string {
const hours = 6 + ((dayTime01 - SUNRISE01) / (SUNSET01 - SUNRISE01)) * 15;
const h = Math.floor(hours), m = Math.round((hours - h) * 60);
return `${String(h + Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}

export function cloneAtmosphere(a: Atmosphere | null | undefined): Atmosphere {
return {
sun: a?.sun ? { ...a.sun } : null,
fog: a?.fog ? { ...a.fog } : null,
sky: a?.sky ? { ...a.sky } : null,
hosted: a?.hosted ? { ...a.hosted } : null,
};
}
22 changes: 22 additions & 0 deletions src/core/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { GridCoord } from "./math";
import { CELL, MAP_SIZE, coordEquals, newId } from "./math";
import type { BaseType, MapBase, Mood } from "./mapbase";
import { baseTypeOf, parseDecoration } from "./mapbase";
import type { Atmosphere } from "./atmosphere";
import { cloneAtmosphere } from "./atmosphere";

export interface DocumentEvents extends Record<string, unknown> {
placementAdded: { layer: Layer; placement: Placement };
Expand All @@ -14,6 +16,8 @@ export interface DocumentEvents extends Record<string, unknown> {
/** Any layer property change: name, visibility, settings, transform. */
layerChanged: { layer: Layer };
activeLayerChanged: { layer: Layer };
/** Custom sun, fog or sky changed (core/atmosphere.ts). */
atmosphereChanged: Record<string, never>;
/** Whole document replaced (new/import). Rebuild everything. */
reset: Record<string, never>;
/** Map base or mood changed (size, decoration, lighting). */
Expand All @@ -34,6 +38,10 @@ export class MapDocument {
decorationBase = "48x48Screen155";
mood: Mood = "Day";
size: GridCoord = MAP_SIZE;
/** The game's map uid (from import) — the key Nadeo's record services use. */
mapUid: string | null = null;
/** Whether the imported map file carried a validation ghost (null = unknown). */
validationGhost: boolean | null = null;
/** The map's own custom texture pack (mod) URL, from import — if any. */
modUrl: string | null = null;
/** Slug of the mod currently APPLIED (any downloaded mod, not just the map's). */
Expand All @@ -52,6 +60,14 @@ export class MapDocument {
this.events.emit("mapChanged", {});
}

/** Custom sun, fog and sky (all null = the mood's own). */
atmosphere: Atmosphere = cloneAtmosphere(null);

setAtmosphere(patch: Partial<Atmosphere>): void {
this.atmosphere = { ...this.atmosphere, ...patch };
this.events.emit("atmosphereChanged", {});
}

/** Full game decoration id, e.g. "NoStadium48x48Sunset". */
get decoration(): string {
return this.decorationBase + this.mood;
Expand Down Expand Up @@ -191,9 +207,12 @@ export class MapDocument {
name?: string;
decoration?: string;
size?: GridCoord;
mapUid?: string | null;
validationGhost?: boolean | null;
modUrl?: string | null;
activeMod?: string | null;
colorPalette?: string;
atmosphere?: Atmosphere | null;
},
): void {
this.layerList = layers.length ? layers : [createLayer("Base")];
Expand All @@ -205,10 +224,13 @@ export class MapDocument {
this.mood = parsed.mood;
}
if (meta?.size) this.size = meta.size;
this.mapUid = meta?.mapUid ?? null;
this.validationGhost = meta?.validationGhost ?? null;
// Mods are per-map: opening a map replaces them (undefined = clear).
this.modUrl = meta?.modUrl ?? null;
this.activeMod = meta?.activeMod ?? null;
this.colorPalette = meta?.colorPalette ?? "Classic";
this.atmosphere = cloneAtmosphere(meta?.atmosphere);
this.events.emit("reset", {});
this.events.emit("mapChanged", {});
}
Expand Down
Loading
Loading