diff --git a/Aetheria.Rts.Web/Client/app.ts b/Aetheria.Rts.Web/Client/app.ts index 55a7f6bd8..2121c9cad 100644 --- a/Aetheria.Rts.Web/Client/app.ts +++ b/Aetheria.Rts.Web/Client/app.ts @@ -140,15 +140,11 @@ function numberProp(value: unknown, fallback: number): number { } function resolveAetheriaAssetUrl(uri: string): string { - if (!uri.startsWith("resources://")) { + if (!uri.startsWith("cultmesh://")) { return uri; } - const resourcePath = uri.slice("resources://".length).replace(/^\/+/, ""); - const filePath = resourcePath.match(/\.[a-z0-9]+$/i) - ? resourcePath - : `${resourcePath}.png`; - return new URL(`../../Assets/Resources/${filePath}`, document.baseURI).href; + return `aetheria-cdn://asset?uri=${encodeURIComponent(uri)}`; } async function submitEveCommand(intent: EveCommandIntent): Promise { diff --git a/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts index 86ec6f2a9..c3cf28adf 100644 --- a/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts +++ b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts @@ -64,6 +64,7 @@ import { const connectionId = 0x43554c54; const eveSurfaceSchemaId = "gamecult.eve.surface.v1"; const defaultEveSurfaceRecordKey = "eve:surface:aetheria.daemon.game"; +const cultMeshCdnAssetBlobSchemaId = "gamecult.cultmesh.cdn.asset_blob.v1"; type AetheriaPublicationDocumentSpec = CultMeshPublicationDocumentBinding & { readonly localPath: string; @@ -326,6 +327,33 @@ export class AetheriaCultMeshClient { return this.aetheria.daemon.assetManifest(); } + public async assetBlob(uri: string): Promise<{ bytes: Uint8Array; mimeType: string }> { + const recordKey = normalizeCultMeshAssetUri(uri); + const document = CultMesh.documentFromPublication( + { + kind: "peer-snapshot", + peer: () => this.peer(), + endpoint: this.resolvedRudpEndpoint(), + }, + cultMeshCdnAssetBlobSchemaId, + recordKey, + { + documentId: recordKey, + routeHint: this.queryVerse.context.routeHint, + sourceId: recordKey, + timeoutMs: 1500, + pollMs: 50, + messageIdPrefix: `${this.runtimeId}:asset`, + }, + ); + const bytes = normalizeAssetBlob(await document.latest(this.queryContext())); + const manifest = await this.assetManifest().catch(() => null); + return { + bytes, + mimeType: resolveAssetMimeType(recordKey, manifest), + }; + } + public async eveSurface(request: AetheriaEveSurfaceRequest = {}): Promise { const recordKey = eveSurfaceRecordKey(request); const document = CultMesh.documentFromPublication( @@ -658,6 +686,38 @@ function normalizeDaemonTarget(target: string | AetheriaCultMeshDaemonTarget): R }; } +function normalizeCultMeshAssetUri(uri: string): string { + const value = (uri ?? "").trim(); + if (!value.toLowerCase().startsWith("cultmesh://")) { + throw new Error(`Aetheria asset URI must be CultMesh-native: ${value}`); + } + return value; +} + +function normalizeAssetBlob(value: unknown): Uint8Array { + if (value instanceof Uint8Array) + return value; + if (Array.isArray(value)) + return Uint8Array.from(value.map(entry => typeof entry === "number" ? entry : 0)); + throw new Error("Aetheria CultMesh CDN asset blob did not decode to bytes."); +} + +function resolveAssetMimeType(recordKey: string, manifest: AssetManifestDocument | null): string { + const entry = manifest?.assets.find(candidate => candidate.ref.uri === recordKey); + return entry?.ref.mimeType?.trim() || mimeTypeFromUri(recordKey); +} + +function mimeTypeFromUri(uri: string): string { + const normalized = uri.toLowerCase(); + if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) + return "image/jpeg"; + if (normalized.endsWith(".svg")) + return "image/svg+xml"; + if (normalized.endsWith(".png") || normalized.startsWith("cultmesh://")) + return "image/png"; + return "application/octet-stream"; +} + async function retryTransientPublicationRead(read: () => Promise): Promise { let lastError: unknown; for (let attempt = 0; attempt < 40; attempt += 1) { diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts index 5d4c9bfe2..72ecc54ae 100644 --- a/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts @@ -522,7 +522,7 @@ function toProjectileViewObject(projectile: unknown[]): ViewObject { targetEntityIndex: num(projectile[projectileSlots.targetEntityIndex], -1), isActive: projectile[projectileSlots.active] !== false, visibility: num(projectile[projectileSlots.radius]), - iconAsset: spriteAsset("aetheria.asset.sprite.game.projectile", "Sprites/Icons/Lightning Bolt"), + iconAsset: spriteAsset("map.entity.projectile"), status: { hull: num(projectile[projectileSlots.damage]), shield: 0, @@ -796,40 +796,46 @@ function toAssetManifestEntry(entry: unknown[]): AssetManifestDocument["assets"] function entityIconAsset(kind: string, controlled: boolean): AssetRef { if (controlled) - return spriteAsset("map.entity.player", "Sprites/Icons/Stroked/Ship"); + return spriteAsset("map.entity.player"); const normalized = kind.trim().toLowerCase(); if (normalized.includes("station")) - return spriteAsset("map.entity.station", "Sprites/Icons/station1"); + return spriteAsset("map.entity.station"); if (normalized.includes("orbital")) - return spriteAsset("map.entity.orbital", "Sprites/Icons/Stroked/orbital"); + return spriteAsset("map.entity.orbital"); + if (normalized.includes("projectile")) + return spriteAsset("map.entity.projectile"); - return spriteAsset("map.entity.ship", "Sprites/Icons/Stroked/Ship"); + return spriteAsset("map.entity.ship"); } function bodyIconAsset(kind: string): AssetRef { const normalized = kind.trim().toLowerCase(); if (normalized.includes("sun") || normalized.includes("star")) - return spriteAsset("map.body.sun", "Sprites/Icons/Stroked/Sun"); + return spriteAsset("map.body.sun"); if (normalized.includes("asteroid")) - return spriteAsset("map.body.asteroid", "Sprites/Icons/Stroked/Planet"); + return spriteAsset("map.body.asteroid"); - return spriteAsset("map.body.planet", "Sprites/Icons/Stroked/Planet"); + return spriteAsset("map.body.planet"); } function itemIconAsset(itemKey: string): AssetRef { const key = itemKey.trim(); return key.length > 0 - ? textureAsset(`item.${key}.icon`, `item.${key}.icon`) + ? textureAsset(`item.${key}.icon`) : emptyAsset("texture"); } -function spriteAsset(assetKey: string, resourcePath: string): AssetRef { - return asset(assetKey, "sprite", `resources://${resourcePath}`, "resources", "image/*"); +function spriteAsset(assetKey: string): AssetRef { + return asset(assetKey, "sprite", cultMeshAssetUri(assetKey), "cultmesh", "image/*"); } -function textureAsset(assetKey: string, resourcePath: string): AssetRef { - return asset(assetKey, "texture", resourcePath.startsWith("resources://") ? resourcePath : `resources://${resourcePath}`, "resources", "image/*"); +function textureAsset(assetKey: string): AssetRef { + return asset(assetKey, "texture", cultMeshAssetUri(assetKey), "cultmesh", "image/*"); +} + +function cultMeshAssetUri(assetKey: string): string { + return `cultmesh://aetheria/assets/${assetKey.trim().replace(/[.\\]+/g, "/").replace(/^\/+|\/+$/g, "")}`; } function assetRef(value: unknown[], fallback: AssetRef = emptyAsset()): AssetRef { diff --git a/Aetheria.Rts.Web/Electron/main.ts b/Aetheria.Rts.Web/Electron/main.ts index b9c8174c9..ccf31d7d2 100644 --- a/Aetheria.Rts.Web/Electron/main.ts +++ b/Aetheria.Rts.Web/Electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, shell } from "electron"; +import { app, BrowserWindow, ipcMain, protocol, shell } from "electron"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -27,6 +27,7 @@ const clientCultMeshAdvertiseHost = process.env.AETHERIA_CLIENT_CULTMESH_ADVERTI const localDaemonRudpEndpoint = launchLocalDaemon ? `rudp://127.0.0.1:${clientCultMeshPort}` : ""; const electronSmoke = process.env.AETHERIA_ELECTRON_SMOKE === "1"; const electronSmokeResultPath = process.env.AETHERIA_ELECTRON_SMOKE_RESULT; +const assetProtocol = "aetheria-cdn"; let daemonProcess: ChildProcessWithoutNullStreams | null = null; let mainWindow: BrowserWindow | null = null; @@ -36,6 +37,7 @@ let isQuitting = false; app.whenReady().then(async () => { mainWindow = createWindow(); registerIpc(); + registerAssetProtocol(); showStartup("Preparing Aetheria Starbridge", "Building daemon if needed."); try { @@ -336,6 +338,29 @@ function registerIpc(): void { }); } +function registerAssetProtocol(): void { + protocol.handle(assetProtocol, async request => { + try { + const uri = new URL(request.url).searchParams.get("uri") ?? ""; + if (!aetheriaClient) { + return new Response("Aetheria CultMesh client is not initialized.", { status: 503 }); + } + + const asset = await aetheriaClient.assetBlob(uri); + const body = Uint8Array.from(asset.bytes).buffer as ArrayBuffer; + return new Response(body, { + status: 200, + headers: { + "content-type": asset.mimeType, + "cache-control": "no-store", + }, + }); + } catch (error) { + return new Response(error instanceof Error ? error.message : String(error), { status: 404 }); + } + }); +} + function requireClient(): AetheriaCultMeshClient { if (!aetheriaClient) throw new Error("Aetheria CultMesh client is not initialized."); diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 8c78e009b..8cc70f8cd 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16290,6 +16290,9 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "AetheriaRuntimeClientTargetStore.cs"); var runtimeClientProviderPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "AetheriaUnityRuntimeClientProvider.cs"); var rtsLocalDocumentsPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "aetheria-rts-local-documents.ts"); + var rtsElectronMainPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "main.ts"); + var rtsElectronCultMeshPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "aetheria-cultmesh.ts"); + var rtsClientAppPath = Path.Combine(root, "Aetheria.Rts.Web", "Client", "app.ts"); var daemonRuntimeDocumentTestsPath = Path.Combine(root, "Assets", "Scripts", "Tests", "DaemonRuntimeDocumentTests.cs"); var authoritySmokePath = Path.Combine(root, "Aetheria.State.AuthoritySmoke", "Program.cs"); var freezePath = Path.Combine(root, "Aetheria.State.Freeze", "Program.cs"); @@ -16326,6 +16329,15 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) var rtsLocalDocuments = File.Exists(rtsLocalDocumentsPath) ? File.ReadAllText(rtsLocalDocumentsPath) : throw new InvalidOperationException("Cannot verify daemon projectile authority; Electron local projection is missing."); + var rtsElectronMain = File.Exists(rtsElectronMainPath) + ? File.ReadAllText(rtsElectronMainPath) + : throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; Electron main process is missing."); + var rtsElectronCultMesh = File.Exists(rtsElectronCultMeshPath) + ? File.ReadAllText(rtsElectronCultMeshPath) + : throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; Electron CultMesh client is missing."); + var rtsClientApp = File.Exists(rtsClientAppPath) + ? File.ReadAllText(rtsClientAppPath) + : throw new InvalidOperationException("Cannot verify Electron CultMesh CDN asset authority; renderer app is missing."); var daemonRuntimeDocumentTests = File.Exists(daemonRuntimeDocumentTestsPath) ? File.ReadAllText(daemonRuntimeDocumentTestsPath) : throw new InvalidOperationException("Cannot verify daemon projectile authority; daemon runtime document tests are missing."); @@ -16566,6 +16578,25 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) string.Join(", ", staleRuntimeSpecificAssetSources)); } + if (rtsLocalDocuments.Contains("resources://", StringComparison.Ordinal) || + rtsLocalDocuments.Contains("\"resources\"", StringComparison.Ordinal) || + rtsClientApp.Contains("Assets/Resources", StringComparison.Ordinal) || + rtsClientApp.Contains("resources://", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Electron Eve lowering must not synthesize renderer-local Resources asset refs; assets resolve through CultMesh CDN."); + } + if (!rtsClientApp.Contains("aetheria-cdn://asset?uri=", StringComparison.Ordinal) || + !rtsClientApp.Contains("uri.startsWith(\"cultmesh://\")", StringComparison.Ordinal) || + !rtsElectronMain.Contains("protocol.handle(assetProtocol", StringComparison.Ordinal) || + !rtsElectronMain.Contains("aetheriaClient.assetBlob(uri)", StringComparison.Ordinal) || + !rtsElectronCultMesh.Contains("cultMeshCdnAssetBlobSchemaId", StringComparison.Ordinal) || + !rtsElectronCultMesh.Contains("public async assetBlob(uri: string)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Electron Eve lowering must resolve CultMesh asset refs through the daemon CultMesh CDN protocol bridge."); + } + if (daemonTickRunner.Contains("AetheriaRuntimeDaemonFrameStore.PublishFrame", StringComparison.Ordinal)) { throw new InvalidOperationException(