diff --git a/README.RU.md b/README.RU.md index aabbeb8..8beaed0 100644 --- a/README.RU.md +++ b/README.RU.md @@ -24,6 +24,27 @@ Работа с данными построена на принципе proto-first, но с сохранением поддержки старых json-форматов экспорта. Если Вы планируете создавать свое решение с использованием данного генератора, рекомендую рассчитывать только на бинарные proto-файлы так как импорт/экспорт в json сфокусирован в первую очередь на поддержке старых вариантов и первоначальных watabou-генераторов. proto-файлы в свою очередь гарантируют долгосрочную поддержку даже в случае добавления нового функционала - старые сохраненные файлы будут поддерживаться и в новых версиях, без потери обратной совместимости. +### Формирование бинарных файлов + +Все бинарные файлы формируются на основании proto-файлов которые лежат в папке: [protobuf](./protobuf) + +Импорт принимает как прямые бинарные прото-обьекты из корневых структур, так и обернутые в указатель типа с CRC32. + +Обертка формируется простым правилом: `|DataType uint32|{protobuf binary}|crc32IEEE({protobuf binary})` + +Указатели так же в proto: [DataType](./protobuf/data/enum.proto) + +Список корневых структур: + +- [GeoObj](./protobuf/data/geo/obj.proto) +- [DwellingsObj](./protobuf/data/dwellings/obj.proto) +- [PaletteMfcgObj](./protobuf/data/palette/mfcg.proto) +- [PaletteVillageObj](./protobuf/data/palette/village.proto) +- [PaletteDwellingsObj](./protobuf/data/palette/dwellings.proto) +- [PaletteViewerObj](./protobuf/data/palette/viewer.proto) +- [PaletteCaveObj](./protobuf/data/palette/cave.proto) +- [PaletteGladeObj](./protobuf/data/palette/glade.proto) + ## О проекте Этот проект — объединение в один проект генераторов карт от [watabou](https://github.com/watabou/), которые он публиковал на [watabou.github.io](https://watabou.github.io). Не все из них доступны публично, а даже то, что доступно, написано на не самом популярном языке. diff --git a/README.md b/README.md index c47ca6e..d9207a6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,27 @@ If needed, you can “cut out” only one specific generator — at the code lev Work with data is built on the proto-first principle, while keeping support for older JSON export formats. If you plan to create your own solution using this generator, I recommend relying only on binary proto files, because JSON import/export is primarily focused on supporting older variants and the original watabou generators. Proto files, in turn, guarantee long-term support even if new functionality is added — old saved files will be supported in new versions without losing backward compatibility. +### Binary file structure + +All binary files are generated from proto definitions located in the [protobuf](./protobuf) directory. + +Import accepts both raw binary proto objects from root structures and objects wrapped in a typed envelope with CRC32. + +The envelope follows a simple format: `|DataType uint32|{protobuf binary}|crc32IEEE({protobuf binary})` + +Type identifiers are also defined in proto: [DataType](./protobuf/data/enum.proto) + +Root structures: + +- [GeoObj](./protobuf/data/geo/obj.proto) +- [DwellingsObj](./protobuf/data/dwellings/obj.proto) +- [PaletteMfcgObj](./protobuf/data/palette/mfcg.proto) +- [PaletteVillageObj](./protobuf/data/palette/village.proto) +- [PaletteDwellingsObj](./protobuf/data/palette/dwellings.proto) +- [PaletteViewerObj](./protobuf/data/palette/viewer.proto) +- [PaletteCaveObj](./protobuf/data/palette/cave.proto) +- [PaletteGladeObj](./protobuf/data/palette/glade.proto) + ## About the project This project is a consolidation into one project of map generators by [watabou](https://github.com/watabou/) that he published at [watabou.github.io](https://watabou.github.io). Not all of them are publicly available, and even what is available is written in a not-so-popular language. diff --git a/package.json b/package.json index c7fdd8b..7bad114 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fantasy-maphub", "name_full": "Fantasy MapHub Generators", - "version": "1.2.0", + "version": "1.2.1-add-data-check-bin-data.1", "description": "An offline-first hub that bundles several Watabou map generators into one consistent web app: same UI patterns, local assets, and modern import/export. It adds OpenAPI docs, proto-first serialization (including pure protobuf files), and a PWA build so everything works without an internet connection.", "license": "MPL-2.0", "author": "mail@sunsung.fun", diff --git a/protobuf/data/enum.proto b/protobuf/data/enum.proto new file mode 100644 index 0000000..18c27d0 --- /dev/null +++ b/protobuf/data/enum.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package data; + +// // // // + +enum DataType { + DATA_UNSPECIFIED = 0; + geo = 1; + dwellings = 2; + reserved 3 to 19; + + palette_cave = 20; + palette_glade = 21; + palette_dwellings = 22; + palette_mfcg = 23; + palette_village = 24; + palette_viewer = 25; + reserved 26 to 59; +}; \ No newline at end of file diff --git a/protobuf/data/palette/village.proto b/protobuf/data/palette/village.proto index da27e6d..447a248 100644 --- a/protobuf/data/palette/village.proto +++ b/protobuf/data/palette/village.proto @@ -47,47 +47,47 @@ message PaletteVillageTerrainObj { } message PaletteVillageHousesObj { - repeated PaletteRgbObj roofLight = 1; - PaletteRgbObj roofStroke = 2; - float roofVariance = 3; - float roofSlope = 4; - PaletteVillageRoofType roofType = 5; + repeated PaletteRgbObj roof_light = 1; + PaletteRgbObj roof_stroke = 2; + float roof_variance = 3; + float roof_slope = 4; + PaletteVillageRoofType roof_type = 5; } message PaletteVillageRoadsObj { PaletteRgbObj road = 1; - float largeRoad = 2; - float smallRoad = 3; - PaletteVillageOutlineType outlineRoads = 4; - bool mergeRoads = 5; + float large_road = 2; + float small_road = 3; + PaletteVillageOutlineType outline_roads = 4; + bool merge_roads = 5; } message PaletteVillageFieldsObj { - repeated PaletteRgbObj fieldLight = 1; - PaletteRgbObj fieldFurrow = 2; - float fieldVariance = 3; - PaletteVillageOutlineType outlineFields = 4; + repeated PaletteRgbObj field_light = 1; + PaletteRgbObj field_furrow = 2; + float field_variance = 3; + PaletteVillageOutlineType outline_fields = 4; } message PaletteVillageWaterObj { - PaletteRgbObj waterShallow = 1; - PaletteRgbObj waterDeep = 2; - PaletteRgbObj waterTide = 3; - uint32 shallowBands = 4; + PaletteRgbObj water_shallow = 1; + PaletteRgbObj water_deep = 2; + PaletteRgbObj water_tide = 3; + uint32 shallow_bands = 4; } message PaletteVillageTreesObj { repeated PaletteRgbObj tree = 1; PaletteRgbObj thicket = 2; - PaletteRgbObj treeDetails = 3; + PaletteRgbObj tree_details = 3; float treeVariance = 4; - PaletteVillageTreeShapeType treeShape = 5; + PaletteVillageTreeShapeType tree_shape = 5; } message PaletteVillageLightingObj { - PaletteRgbObj shadowColor = 1; - float shadowLength = 2; - uint32 shadowAngleDeg = 3; + PaletteRgbObj shadow_color = 1; + float shadow_length = 2; + uint32 shadow_angle_deg = 3; PaletteRgbObj lights = 4; } @@ -100,8 +100,8 @@ message PaletteVillageTextObj { message PaletteVillageMiscObj { PaletteRgbObj ink = 1; PaletteRgbObj paper = 2; - float strokeNormal = 3; - float strokeThin = 4; + float stroke_normal = 3; + float stroke_thin = 4; } // diff --git a/src/js/Dwellings.js b/src/js/Dwellings.js index 24b0155..58bde10 100644 --- a/src/js/Dwellings.js +++ b/src/js/Dwellings.js @@ -7,6 +7,7 @@ import * as FuncProto from "./shared/proto.js"; import * as DataDwellings from "./shared/data/Dwellings.js"; import * as DataProto from "./struct/data.js"; +import * as FuncBin from "./shared/data/bin-verify.js"; const params = FuncProto.initParams(JSON.parse(String.raw`{{EMBED_PARAMETERS_JSON_DWELLINGS}}`)); @@ -5019,7 +5020,7 @@ var $lime_init = function (K, v) { onSave: function (a, fmt) { var pdo = DataDwellings.paletteObjFromLegacyJsonText(a.json()); if (fmt === "proto") { - var bytes = DataProto.data.PaletteDwellingsObj.encode(pdo).finish(); + var bytes = DataDwellings.paletteProtoBytesFromObj(pdo); id.saveText(bytes, this.getName(a) + ".palette.dw.pb", "application/octet-stream"); } else { var json = DataDwellings.paletteLegacyJsonFromObj(pdo); @@ -6567,10 +6568,9 @@ var $lime_init = function (K, v) { return b }; sb.exportAsProto=function(a){ - var b=sb.exportProto(a),c=DataProto.data.DwellingsObj.encode(b).finish(),d=c.buffer.slice(c.byteOffset,c.byteOffset+c.byteLength); - c=Pd.fromArrayBuffer(d); - d=id.fixName(a.name); - id.saveBinary(c,d+".dw.pb","application/x-protobuf"); + var b=sb.exportProto(a),c=DataProto.data.DwellingsObj.encode(b).finish(); + var d = FuncBin.exportBin(c, DataProto.data.DataType.dwellings); + id.saveBinary(Pd.fromArrayBuffer(d),id.fixName(a.name)+".dw.pb","application/x-protobuf"); return b }; sb.house2proto=function(a){ diff --git a/src/js/ToyTown2.js b/src/js/ToyTown2.js index 4119e97..e019b01 100644 --- a/src/js/ToyTown2.js +++ b/src/js/ToyTown2.js @@ -15721,7 +15721,7 @@ if (params !== null) (function (S, u) { onSave: function (a, fmt) { var pvo = DataViewer.paletteObjFromLegacyJsonText(a.json()); if (fmt === "proto") { - var bytes = DataProto.data.PaletteViewerObj.encode(pvo).finish(); + var bytes = DataViewer.paletteProtoBytesFromObj(pvo); Og.saveText(bytes, this.getName(a) + ".palette.vr.pb", "application/octet-stream"); } else { var json = DataViewer.paletteLegacyJsonFromObj(pvo); diff --git a/src/js/Village.js b/src/js/Village.js index a2294f3..22bc638 100644 --- a/src/js/Village.js +++ b/src/js/Village.js @@ -7,6 +7,7 @@ import * as FuncProto from "./shared/proto.js"; import * as DataVillage from "./shared/data/Village.js"; import * as DataProto from "./struct/data.js"; +import * as FuncBin from "./shared/data/bin-verify.js"; const params = FuncProto.initParams(JSON.parse(String.raw`{{EMBED_PARAMETERS_JSON_VILLAGE}}`)); @@ -4922,15 +4923,14 @@ var $lime_init = function (E, u) { var self = this; var doSave = function(fmt) { + var pvo = DataVillage.paletteObjFromLegacyJsonText(a.json()); if (fmt === "JSON") { - var pvo = DataVillage.paletteObjFromLegacyJsonText(a.json()); var json = DataVillage.paletteLegacyJsonFromObj(pvo); Sd.saveText(json, self.getName(a) + ".palette.vg.json", "application/json"); return; } if (fmt === "PROTO") { - var pvo = DataVillage.paletteObjFromLegacyJsonText(a.json()); - var bytes = DataProto.data.PaletteVillageObj.encode(pvo).finish(); + var bytes = DataVillage.paletteProtoBytesFromObj(pvo); Sd.saveText(bytes, self.getName(a) + ".palette.vg.pb", "application/octet-stream"); return; } @@ -9406,9 +9406,9 @@ var $lime_init = function (E, u) { var b = Hh.getProto(a), c = Hh.stringifyProto(b); return Sd.saveText(c, a.name + ".vg.json", "application/json"), b }, Hh.exportBinary = function (a) { - var b = Hh.getProto(a), c = DataProto.data.GeoObj.encode(b).finish(), - d = wd.fromArrayBuffer(c.buffer.slice(c.byteOffset, c.byteOffset + c.byteLength)); - return Sd.saveBinary(d, a.name + ".vg.pb", "application/x-protobuf"), b + var b = Hh.getProto(a), c = DataProto.data.GeoObj.encode(b).finish(); + var d = FuncBin.exportBin(c, DataProto.data.DataType.geo); + return Sd.saveBinary(wd.fromArrayBuffer(d), a.name + ".vg.pb", "application/x-protobuf"), b }, Hh.getData = function (a) { Ua.CX = 0, Ua.CY = 0, Ua.SCALE = .7; diff --git a/src/js/mfcg.js b/src/js/mfcg.js index c2382f1..c981e59 100644 --- a/src/js/mfcg.js +++ b/src/js/mfcg.js @@ -5,8 +5,11 @@ import * as OthersShared from "./shared/others.js"; import * as FuncProto from "./shared/proto.js"; import * as DataMfcg from "./shared/data/mfcg.js"; +import * as FuncBin from "./shared/data/bin-verify.js"; import * as DataProto from "./struct/data.js"; +import {paletteProtoBytesFromObj} from "./shared/data/mfcg.js"; +import {exportBin} from "./shared/data/bin-verify.js"; const params = FuncProto.initParams(JSON.parse(String.raw`{{EMBED_PARAMETERS_JSON_MFCG}}`)); @@ -7882,7 +7885,8 @@ var $lime_init = function (A, t) { }; be.asPROTO = function () { var a = Ub.instance, b = lg.export(a), c = DataProto.data.GeoObj.encode(b).finish(); - a = a.name, ge.saveBinary(Td.fromArrayBuffer(c.buffer), "" + a + ".mf.pb", "application/x-protobuf") + var bb = FuncBin.exportBin(c, DataProto.data.DataType.geo); + a = a.name, ge.saveBinary(Td.fromArrayBuffer(bb), "" + a + ".mf.pb", "application/x-protobuf") }; var lg = function () { }; diff --git a/src/js/shared/data/Cave.js b/src/js/shared/data/Cave.js index d330395..9bbe0e9 100644 --- a/src/js/shared/data/Cave.js +++ b/src/js/shared/data/Cave.js @@ -1,5 +1,5 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; const LEGACY_KEYS = [ @@ -85,7 +85,7 @@ export function paletteObjFromLegacyJsonText(text) { if (!isPlainObject(obj)) throw PaletteFunc.unknownPalette(); - assertExpectedLegacyRootType("PaletteCaveObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_cave, obj); if (isPlainObject(obj.colors) && isPlainObject(obj.shadow) && isPlainObject(obj.strokes) && isPlainObject(obj.hatching)) { return normalizePaletteCaveObjLike(obj); @@ -152,10 +152,11 @@ export function paletteLegacyJsonFromObj(m) { export function paletteProtoBytesFromObj(m) { let n = normalizePaletteCaveObjLike(m); - return DataProto.data.PaletteCaveObj.encode(n).finish(); + let raw = DataProto.data.PaletteCaveObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_cave, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteCaveObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_cave, paletteObjFromLegacyJsonText, data); return normalizePaletteCaveObjLike(msg); } diff --git a/src/js/shared/data/Dwellings.js b/src/js/shared/data/Dwellings.js index 5953638..a1edfba 100644 --- a/src/js/shared/data/Dwellings.js +++ b/src/js/shared/data/Dwellings.js @@ -1,6 +1,7 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; +import * as FuncBin from "./bin-verify.js"; const COLOR_SPECS = [ { legacy: "colorInk", proto: "ink" }, @@ -120,7 +121,7 @@ function paletteDwellingsObjFromLegacyJsonInternal(obj) { if (!isPlainObject(obj)) throw PaletteFunc.unknownPalette(); if (Array.isArray(obj.floors) && obj.features == null) throw new Error("Dwellings, not Palette."); - assertExpectedLegacyRootType("PaletteDwellingsObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_dwellings, obj); if (isPlainObject(obj.colors) && isPlainObject(obj.strokes) && isPlainObject(obj.misc)) { return normalizePaletteDwellingsObjLike(obj); @@ -212,10 +213,11 @@ export function paletteLegacyJsonFromObj(pdo) { export function paletteProtoBytesFromObj(pdo) { let n = normalizePaletteDwellingsObjLike(pdo); - return DataProto.data.PaletteDwellingsObj.encode(n).finish(); + let raw = DataProto.data.PaletteDwellingsObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_dwellings, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteDwellingsObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_dwellings, paletteObjFromLegacyJsonText, data); return normalizePaletteDwellingsObjLike(msg); } diff --git a/src/js/shared/data/Glade.js b/src/js/shared/data/Glade.js index 88b4fca..38071a0 100644 --- a/src/js/shared/data/Glade.js +++ b/src/js/shared/data/Glade.js @@ -1,6 +1,7 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; +import * as FuncBin from "./bin-verify.js"; const LEGACY_KEYS = [ "ink", "marks", "tree", "ground", "treeDetails", "thicket", @@ -147,7 +148,7 @@ export function paletteObjFromLegacyJsonText(text) { let obj = null; try { obj = JSON.parse(text); } catch (e) { throw new Error("An error occurred while parsing: " + (e && e.message ? e.message : String(e))); } - assertExpectedLegacyRootType("PaletteGladeObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_glade, obj); if (isPlainObject(obj) && isPlainObject(obj.colors) && isPlainObject(obj.trees) && isPlainObject(obj.shadow) && isPlainObject(obj.strokes) && isPlainObject(obj.misc)) { return normalizePaletteGladeObjLike(obj); @@ -329,10 +330,11 @@ export function paletteLegacyJsonFromObj(p) { export function paletteProtoBytesFromObj(m) { let n = normalizePaletteGladeObjLike(m); - return DataProto.data.PaletteGladeObj.encode(n).finish(); + let raw = DataProto.data.PaletteGladeObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_glade, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteGladeObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_glade, paletteObjFromLegacyJsonText, data); return normalizePaletteGladeObjLike(msg); } diff --git a/src/js/shared/data/Viewer.js b/src/js/shared/data/Viewer.js index 5080779..505e23a 100644 --- a/src/js/shared/data/Viewer.js +++ b/src/js/shared/data/Viewer.js @@ -1,6 +1,7 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; +import * as FuncBin from "./bin-verify.js"; const COLOR_KEYS = ["ground", "fields", "greens", "foliage", "roads", "water", "walls1", "walls2", "roofs1", "roofs2"]; const LIGHTING_COLOR_KEYS = ["sky1", "sky2", "sun", "windows"]; @@ -106,7 +107,7 @@ export function paletteObjFromLegacyJsonText(text) { let obj = null; try { obj = JSON.parse(text); } catch (e) { throw new Error("An error occurred while parsing: " + (e && e.message ? e.message : String(e))); } - assertExpectedLegacyRootType("PaletteViewerObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_viewer, obj); if (isPlainObject(obj) && isPlainObject(obj.colors) && isPlainObject(obj.lighting) && isPlainObject(obj.shapes)) { return normalizePaletteViewerObjLike(obj); @@ -202,10 +203,11 @@ export function paletteLegacyJsonFromObj(pvo) { export function paletteProtoBytesFromObj(pvo) { let n = normalizePaletteViewerObjLike(pvo); - return DataProto.data.PaletteViewerObj.encode(n).finish(); + let raw = DataProto.data.PaletteViewerObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_viewer, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteViewerObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_viewer, paletteObjFromLegacyJsonText, data); return normalizePaletteViewerObjLike(msg); } diff --git a/src/js/shared/data/Village.js b/src/js/shared/data/Village.js index 43cad87..c43c5cb 100644 --- a/src/js/shared/data/Village.js +++ b/src/js/shared/data/Village.js @@ -1,6 +1,7 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; +import * as FuncBin from "./bin-verify.js"; const LEGACY_KEYS = [ "ground", "relief", "sand", "plank", @@ -294,7 +295,7 @@ export function paletteObjFromLegacyJsonText(text) { let obj = null; try { obj = JSON.parse(text); } catch (e) { throw new Error("An error occurred while parsing: " + (e && e.message ? e.message : String(e))); } - assertExpectedLegacyRootType("PaletteVillageObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_village, obj); if (isPlainObject(obj) && isPlainObject(obj.terrain) && isPlainObject(obj.houses) && isPlainObject(obj.roads) && isPlainObject(obj.fields) && isPlainObject(obj.water) && isPlainObject(obj.trees) && isPlainObject(obj.lighting) && isPlainObject(obj.text) && isPlainObject(obj.misc)) { return normalizePaletteVillageObjLike(obj); @@ -436,10 +437,11 @@ export function paletteLegacyJsonFromObj(p) { export function paletteProtoBytesFromObj(p) { let n = normalizePaletteVillageObjLike(p); - return DataProto.data.PaletteVillageObj.encode(n).finish(); + let raw = DataProto.data.PaletteVillageObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_village, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteVillageObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_village, paletteObjFromLegacyJsonText, data); return normalizePaletteVillageObjLike(msg); } diff --git a/src/js/shared/data/bin-verify.js b/src/js/shared/data/bin-verify.js new file mode 100644 index 0000000..472d88a --- /dev/null +++ b/src/js/shared/data/bin-verify.js @@ -0,0 +1,106 @@ +const LITTLE_ENDIAN = true; + +const CRC_TABLE = (function makeCrcTable() { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + table[i] = c >>> 0; + } + return table; +})(); + +function crc32IEEE(u8, start = 0, end = u8.length) { + let crc = 0xFFFFFFFF; + for (let i = start; i < end; i++) { + crc = CRC_TABLE[(crc ^ u8[i]) & 0xFF] ^ (crc >>> 8); + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +function toU8(input, byteOffset, byteLength) { + if (input && typeof input === "object" && input.buffer instanceof ArrayBuffer) { + const off = (byteOffset != null) ? byteOffset : (input.byteOffset >>> 0); + const len = (byteLength != null) ? byteLength : (input.byteLength >>> 0); + return new Uint8Array(input.buffer, off, len); + } + if (input instanceof ArrayBuffer) { + const off = (byteOffset != null) ? (byteOffset >>> 0) : 0; + const len = (byteLength != null) ? (byteLength >>> 0) : (input.byteLength - off); + return new Uint8Array(input, off, len); + } + throw new TypeError("Expected ArrayBuffer or Uint8Array/Buffer (ArrayBufferView)."); +} + +function wipeBytes(input, byteOffset, byteLength) { + try { + const u8 = toU8(input, byteOffset, byteLength); + u8.fill(0); + return true; + } catch (_) { + return false; + } +} + +export function exportBin(input, number, opts) { + const inputOffset = opts && opts.inputOffset; + const inputLength = opts && opts.inputLength; + + const payload = toU8(input, inputOffset, inputLength); + const payloadLen = payload.length; + + const num = (number >>> 0); + const crc = crc32IEEE(payload, 0, payloadLen); + + const out = new ArrayBuffer(payloadLen + 8); + const dv = new DataView(out); + + dv.setUint32(0, num, LITTLE_ENDIAN); + new Uint8Array(out, 4, payloadLen).set(payload); + + dv.setUint32(4 + payloadLen, crc, LITTLE_ENDIAN); + + if (opts && opts.wipeInput) { + payload.fill(0); + } + + return out; +} + +export function importBin(input, opts) { + const inputOffset = opts && opts.inputOffset; + const inputLength = opts && opts.inputLength; + + const frame = toU8(input, inputOffset, inputLength); + const n = frame.length; + + if (n < 8) { + throw new RangeError("The buffer is too small: a minimum of 8 bytes is required."); + } + + const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + + const num = dv.getUint32(0, LITTLE_ENDIAN); + const storedCrc = dv.getUint32(n - 4, LITTLE_ENDIAN); + + const bodyStart = 4; + const bodyEnd = n - 4; + + const calcCrc = crc32IEEE(frame, bodyStart, bodyEnd); + + if (calcCrc !== storedCrc) { + if (opts && opts.wipeInput) frame.fill(0); + throw new Error("CRC32 mismatch"); + } + + const bodyCopy = frame.slice(bodyStart, bodyEnd); + const bodyBuffer = bodyCopy.buffer; + + if (opts && opts.wipeInput) { + frame.fill(0); + } + + return { number: num, buffer: bodyBuffer }; +} diff --git a/src/js/shared/data/data.js b/src/js/shared/data/data.js index f11615c..e07baa6 100644 --- a/src/js/shared/data/data.js +++ b/src/js/shared/data/data.js @@ -1,25 +1,28 @@ import * as DataProto from "../../struct/data.js"; - -const TYPE_LABELS = { - GeoObj: { label: "City/Village", kind: "data" }, - DwellingsObj: { label: "Dwellings", kind: "data" }, - PaletteMfcgObj: { label: "MFCG", kind: "styles" }, - PaletteVillageObj: { label: "Village", kind: "styles" }, - PaletteGladeObj: { label: "Glade", kind: "styles" }, - PaletteViewerObj: { label: "City", kind: "styles" }, - PaletteDwellingsObj: { label: "Dwellings", kind: "styles" }, - PaletteCaveObj: { label: "Cave", kind: "styles" } +import { importBin, exportBin } from "./bin-verify.js"; + +const DT = DataProto.data.DataType; + +const DATA_TYPE_INFO = { + [DT.geo]: { MessageType: DataProto.data.GeoObj, label: "City/Village", kind: "data" }, + [DT.dwellings]: { MessageType: DataProto.data.DwellingsObj, label: "Dwellings", kind: "data" }, + [DT.palette_mfcg]: { MessageType: DataProto.data.PaletteMfcgObj, label: "MFCG", kind: "styles" }, + [DT.palette_village]: { MessageType: DataProto.data.PaletteVillageObj, label: "Village", kind: "styles" }, + [DT.palette_glade]: { MessageType: DataProto.data.PaletteGladeObj, label: "Glade", kind: "styles" }, + [DT.palette_viewer]: { MessageType: DataProto.data.PaletteViewerObj, label: "City", kind: "styles" }, + [DT.palette_dwellings]: { MessageType: DataProto.data.PaletteDwellingsObj, label: "Dwellings", kind: "styles" }, + [DT.palette_cave]: { MessageType: DataProto.data.PaletteCaveObj, label: "Cave", kind: "styles" }, }; -export function describeRootType(typeName) { - let d = TYPE_LABELS[typeName]; +export function describeRootType(dataType) { + let d = DATA_TYPE_INFO[dataType]; if (d) return d; - return { label: String(typeName), kind: "data" }; + return { label: String(dataType), kind: "data" }; } -export function createTypeMismatchError(expectedTypeName, actualTypeName) { - let e = describeRootType(expectedTypeName); - let a = describeRootType(actualTypeName); +export function createTypeMismatchError(expectedDataType, actualDataType) { + let e = describeRootType(expectedDataType); + let a = describeRootType(actualDataType); let expectedText = e.kind === "styles" ? (e.label + " styles") : (e.label + " data"); let actualText = a.kind === "styles" ? (a.label + " styles") : (a.label + " data"); let verb = e.kind === "styles" ? "were" : "was"; @@ -36,29 +39,29 @@ function hasAnyKey(obj, keys) { return false; } -export function detectLegacyRootTypeName(obj) { - if (obj != null && typeof obj === "object" && Array.isArray(obj.floors) && obj.features == null) return "DwellingsObj"; - if (obj != null && typeof obj === "object" && obj.type === "FeatureCollection" && Array.isArray(obj.features)) return "GeoObj"; +export function detectLegacyRootType(obj) { + if (obj != null && typeof obj === "object" && Array.isArray(obj.floors) && obj.features == null) return DT.dwellings; + if (obj != null && typeof obj === "object" && obj.type === "FeatureCollection" && Array.isArray(obj.features)) return DT.geo; if (!isPlainObject(obj)) return null; - if (isPlainObject(obj.colors) && isPlainObject(obj.misc) && isPlainObject(obj.strokes)) return "PaletteDwellingsObj"; - if (isPlainObject(obj.colors) && isPlainObject(obj.shadow) && isPlainObject(obj.strokes) && isPlainObject(obj.hatching)) return "PaletteCaveObj"; + if (isPlainObject(obj.colors) && isPlainObject(obj.misc) && isPlainObject(obj.strokes)) return DT.palette_dwellings; + if (isPlainObject(obj.colors) && isPlainObject(obj.shadow) && isPlainObject(obj.strokes) && isPlainObject(obj.hatching)) return DT.palette_cave; - if (hasAnyKey(obj, ["walls1", "walls2", "roofs1", "roofs2", "sky1", "roofedTowers", "tree_shape"])) return "PaletteViewerObj"; - if (hasAnyKey(obj, ["roofLight", "waterShallow", "waterDeep", "fontHeader", "outlineFields"])) return "PaletteVillageObj"; - if (hasAnyKey(obj, ["thicket", "treeBands", "roadOutline", "grassLength"])) return "PaletteGladeObj"; - if (hasAnyKey(obj, ["colorLight", "colorDark", "tintMethod", "weathering"])) return "PaletteMfcgObj"; + if (hasAnyKey(obj, ["walls1", "walls2", "roofs1", "roofs2", "sky1", "roofedTowers", "tree_shape"])) return DT.palette_viewer; + if (hasAnyKey(obj, ["roofLight", "waterShallow", "waterDeep", "fontHeader", "outlineFields"])) return DT.palette_village; + if (hasAnyKey(obj, ["thicket", "treeBands", "roadOutline", "grassLength"])) return DT.palette_glade; + if (hasAnyKey(obj, ["colorLight", "colorDark", "tintMethod", "weathering"])) return DT.palette_mfcg; - if (hasAnyKey(obj, ["colorWalls", "colorProps", "colorWindows", "colorStairs", "colorRoof", "colorLabels", "strNormal10", "strGrid10", "alphaGrid", "alphaAO", "alphaLights", "fontRoom", "hatching"])) return "PaletteDwellingsObj"; + if (hasAnyKey(obj, ["colorWalls", "colorProps", "colorWindows", "colorStairs", "colorRoof", "colorLabels", "strNormal10", "strGrid10", "alphaGrid", "alphaAO", "alphaLights", "fontRoom", "hatching"])) return DT.palette_dwellings; - if (hasAnyKey(obj, ["colorPage", "colorWater", "shadeAlpha", "shadowAlpha", "shadowDist", "strokeWall", "strokeDetail", "strokeHatch", "strokeGrid", "hatchingStrokes", "hatchingSize", "hatchingDistance", "hatchingStones"])) return "PaletteCaveObj"; + if (hasAnyKey(obj, ["colorPage", "colorWater", "shadeAlpha", "shadowAlpha", "shadowDist", "strokeWall", "strokeDetail", "strokeHatch", "strokeGrid", "hatchingStrokes", "hatchingSize", "hatchingDistance", "hatchingStones"])) return DT.palette_cave; return null; } -export function assertExpectedLegacyRootType(expectedTypeName, obj) { - let actual = detectLegacyRootTypeName(obj); - if (actual != null && actual !== expectedTypeName) throw createTypeMismatchError(expectedTypeName, actual); +export function assertExpectedLegacyRootType(expectedDataType, obj) { + let actual = detectLegacyRootType(obj); + if (actual != null && actual !== expectedDataType) throw createTypeMismatchError(expectedDataType, actual); } export function jsToProtoValue(v) { @@ -202,10 +205,6 @@ export function bytesToUtf8Text(data) { return data != null && typeof data.toString === "function" ? data.toString() : ""; } -const ROOT_DATA_TYPES = [ - "GeoObj", "DwellingsObj", "PaletteMfcgObj", "PaletteVillageObj", - "PaletteGladeObj", "PaletteViewerObj", "PaletteDwellingsObj", "PaletteCaveObj" -]; function stripLengthDelimited(buf) { let pos = 0, len = 0, shift = 0; @@ -229,7 +228,11 @@ function tryDecodeProto(MessageType, buf) { return { msg: null, err: null }; } -export function decodeDataFromFile(expectedTypeName, legacyJsonTextParser, data) { +export function encodeDataToBytes(dataType, protoBytes) { + return exportBin(protoBytes, dataType); +} + +export function decodeDataFromFile(expectedDataType, legacyJsonTextParser, data) { let text = bytesToUtf8Text(data); let isJson = false; try { @@ -252,19 +255,31 @@ export function decodeDataFromFile(expectedTypeName, legacyJsonTextParser, data) let b = toUint8Array(data); if (b == null) throw new Error("Invalid data buffer."); - let expectedType = DataProto.data[expectedTypeName]; - let result = tryDecodeProto(expectedType, b); - if (result.msg != null) return result.msg; + let expectedInfo = DATA_TYPE_INFO[expectedDataType]; - for (let i = 0; i < ROOT_DATA_TYPES.length; i++) { - if (ROOT_DATA_TYPES[i] === expectedTypeName) continue; - let otherType = DataProto.data[ROOT_DATA_TYPES[i]]; - if (otherType == null) continue; - let otherResult = tryDecodeProto(otherType, b); - if (otherResult.msg != null) throw createTypeMismatchError(expectedTypeName, ROOT_DATA_TYPES[i]); + let binResult = null; + try { + binResult = importBin(b); + } catch (binErr) {} + + if (binResult != null) { + if (binResult.number !== expectedDataType) { + let actualInfo = DATA_TYPE_INFO[binResult.number]; + if (actualInfo != null) throw createTypeMismatchError(expectedDataType, binResult.number); + throw new Error("An error occurred while parsing: unknown data type " + binResult.number); + } + let payload = new Uint8Array(binResult.buffer); + try { + return expectedInfo.MessageType.decode(payload); + } catch (decErr) { + throw new Error("An error occurred while parsing: " + (decErr && decErr.message ? decErr.message : String(decErr))); + } } - throw new Error("An error occurred while parsing: unknown protobuf decode error"); + let result = tryDecodeProto(expectedInfo.MessageType, b); + if (result.msg != null) return result.msg; + + throw new Error("An error occurred while parsing: file could not be recognized or decoded."); } export function decodeCityFromJsonText(text) { @@ -275,7 +290,7 @@ export function decodeCityFromJsonText(text) { throw new Error("An error occurred while parsing: " + (e && e.message ? e.message : String(e))); } - assertExpectedLegacyRootType("GeoObj", obj); + assertExpectedLegacyRootType(DT.geo, obj); try { let protoObj = geoJsonToProtoObject(obj); @@ -297,6 +312,6 @@ function decodeCityFromJsonTextAsProto(text) { } export function decodeCityFile(name, data) { - let msg = decodeDataFromFile("GeoObj", decodeCityFromJsonTextAsProto, data); + let msg = decodeDataFromFile(DT.geo, decodeCityFromJsonTextAsProto, data); return geoJsonFromProtoMessage(msg); } diff --git a/src/js/shared/data/mfcg.js b/src/js/shared/data/mfcg.js index 5fecf24..fd0c741 100644 --- a/src/js/shared/data/mfcg.js +++ b/src/js/shared/data/mfcg.js @@ -1,6 +1,7 @@ import * as DataProto from "../../struct/data.js"; -import { assertExpectedLegacyRootType, decodeDataFromFile } from "./data.js"; +import { assertExpectedLegacyRootType, decodeDataFromFile, encodeDataToBytes } from "./data.js"; import * as PaletteFunc from "./palette.js"; +import * as FuncBin from "./bin-verify.js"; const LEGACY_KEYS = [ "colorPaper","colorLight","colorDark","colorRoof","colorWater","colorGreen","colorRoad","colorWall","colorTree","colorLabel", @@ -101,7 +102,7 @@ export function paletteObjFromLegacyJsonText(text) { let obj = null; try { obj = JSON.parse(text); } catch (e) { throw new Error("An error occurred while parsing: " + (e && e.message ? e.message : String(e))); } - assertExpectedLegacyRootType("PaletteMfcgObj", obj); + assertExpectedLegacyRootType(DataProto.data.DataType.palette_mfcg, obj); if (obj != null && typeof obj === "object" && !Array.isArray(obj)) { let c = obj.colors; @@ -186,10 +187,11 @@ export function paletteLegacyJsonFromObj(pmo) { export function paletteProtoBytesFromObj(pmo) { let n = normalizePaletteMfcgObjLike(pmo); - return DataProto.data.PaletteMfcgObj.encode(n).finish(); + let raw = DataProto.data.PaletteMfcgObj.encode(n).finish(); + return encodeDataToBytes(DataProto.data.DataType.palette_mfcg, raw); } export function decodePaletteFile(name, data) { - let msg = decodeDataFromFile("PaletteMfcgObj", paletteObjFromLegacyJsonText, data); + let msg = decodeDataFromFile(DataProto.data.DataType.palette_mfcg, paletteObjFromLegacyJsonText, data); return normalizePaletteMfcgObjLike(msg); } diff --git a/tests/shared/data/Cave.test.js b/tests/shared/data/Cave.test.js index dc7f0e6..f5b2b3d 100644 --- a/tests/shared/data/Cave.test.js +++ b/tests/shared/data/Cave.test.js @@ -180,11 +180,11 @@ describe("Cave: import/export roundtrip", () => { // ─── paletteProtoBytesFromObj ─────────────────────────────────── describe("Cave: paletteProtoBytesFromObj", () => { - it("returns Uint8Array", () => { + it("returns ArrayBuffer", () => { const msg = paletteObjFromLegacyJsonText(validCaveJsonText()); const bytes = paletteProtoBytesFromObj(msg); - expect(bytes).toBeInstanceOf(Uint8Array); - expect(bytes.length).toBeGreaterThan(0); + expect(bytes).toBeInstanceOf(ArrayBuffer); + expect(bytes.byteLength).toBeGreaterThan(0); }); it("proto bytes can be decoded back via decodePaletteFile", () => { diff --git a/tests/shared/data/bin-verify.test.js b/tests/shared/data/bin-verify.test.js new file mode 100644 index 0000000..87aabf6 --- /dev/null +++ b/tests/shared/data/bin-verify.test.js @@ -0,0 +1,301 @@ +import { describe, it, expect } from "vitest"; +import { exportBin, importBin } from "../../../src/js/shared/data/bin-verify.js"; +import { data as DataProto } from "../../../src/js/struct/data.js"; + +// ─── helpers ───────────────────────────────────────────────────── + +/** Encode a proto message to Uint8Array using its .encode() */ +function encodeProto(MessageType, obj) { + return MessageType.encode(MessageType.fromObject(obj)).finish(); +} + +/** DataType enum value mapping (mirrors protobuf/data/enum.proto) */ +const DT = DataProto.DataType; + +// ─── mapping: DataType enum ↔ proto message type names ────────── + +const TYPE_MAP = [ + { enumName: "geo", enumValue: 1, typeName: "GeoObj" }, + { enumName: "dwellings", enumValue: 2, typeName: "DwellingsObj" }, + { enumName: "palette_cave", enumValue: 20, typeName: "PaletteCaveObj" }, + { enumName: "palette_glade", enumValue: 21, typeName: "PaletteGladeObj" }, + { enumName: "palette_dwellings", enumValue: 22, typeName: "PaletteDwellingsObj" }, + { enumName: "palette_mfcg", enumValue: 23, typeName: "PaletteMfcgObj" }, + { enumName: "palette_village", enumValue: 24, typeName: "PaletteVillageObj" }, + { enumName: "palette_viewer", enumValue: 25, typeName: "PaletteViewerObj" }, +]; + +// Minimal valid objects per proto type (enough for encode/decode roundtrip) +const MINIMAL_OBJECTS = { + GeoObj: { type: 1, features: [] }, + DwellingsObj: { floors: [] }, + PaletteCaveObj: { colors: {} }, + PaletteGladeObj: { colors: {} }, + PaletteDwellingsObj: { colors: {} }, + PaletteMfcgObj: { colors: {} }, + PaletteVillageObj: { terrain: {} }, + PaletteViewerObj: { colors: {} }, +}; + +// ─── DataType enum sanity ──────────────────────────────────────── + +describe("DataType enum values", () => { + for (const { enumName, enumValue } of TYPE_MAP) { + it(`DT["${enumName}"] === ${enumValue}`, () => { + expect(DT[enumName]).toBe(enumValue); + }); + it(`DT[${enumValue}] === "${enumName}"`, () => { + expect(DT[enumValue]).toBe(enumName); + }); + } + + it("DATA_UNSPECIFIED === 0", () => { + expect(DT.DATA_UNSPECIFIED).toBe(0); + }); +}); + +// ─── exportBin ─────────────────────────────────────────────────── + +describe("exportBin", () => { + it("returns ArrayBuffer", () => { + const payload = new Uint8Array([1, 2, 3]); + const result = exportBin(payload, DT.geo); + expect(result).toBeInstanceOf(ArrayBuffer); + }); + + it("output size = payload + 8 bytes (4 type + 4 crc)", () => { + const payload = new Uint8Array(10); + const result = exportBin(payload, DT.geo); + expect(result.byteLength).toBe(10 + 8); + }); + + it("writes enum number as LE uint32 at offset 0", () => { + const payload = new Uint8Array([0xAA]); + const result = exportBin(payload, DT.palette_cave); + const dv = new DataView(result); + expect(dv.getUint32(0, true)).toBe(20); + }); + + it("payload bytes are copied starting at offset 4", () => { + const payload = new Uint8Array([0x11, 0x22, 0x33]); + const result = exportBin(payload, DT.dwellings); + const u8 = new Uint8Array(result); + expect(u8[4]).toBe(0x11); + expect(u8[5]).toBe(0x22); + expect(u8[6]).toBe(0x33); + }); + + it("CRC32 is written as LE uint32 at end", () => { + const payload = new Uint8Array([0x01]); + const result = exportBin(payload, DT.geo); + const dv = new DataView(result); + const storedCrc = dv.getUint32(result.byteLength - 4, true); + expect(typeof storedCrc).toBe("number"); + expect(storedCrc).not.toBe(0); + }); + + it("empty payload still produces valid frame (8 bytes)", () => { + const result = exportBin(new Uint8Array(0), DT.geo); + expect(result.byteLength).toBe(8); + }); + + it("wipeInput option zeros source buffer", () => { + const payload = new Uint8Array([1, 2, 3]); + exportBin(payload, DT.geo, { wipeInput: true }); + expect(payload[0]).toBe(0); + expect(payload[1]).toBe(0); + expect(payload[2]).toBe(0); + }); + + it("respects inputOffset / inputLength", () => { + const buf = new Uint8Array([0xFF, 0x11, 0x22, 0xFF]); + const result = exportBin(buf, DT.geo, { inputOffset: 1, inputLength: 2 }); + expect(result.byteLength).toBe(2 + 8); + const u8 = new Uint8Array(result); + expect(u8[4]).toBe(0x11); + expect(u8[5]).toBe(0x22); + }); +}); + +// ─── importBin ─────────────────────────────────────────────────── + +describe("importBin", () => { + it("returns { number, buffer }", () => { + const frame = exportBin(new Uint8Array([0xAB]), DT.palette_mfcg); + const result = importBin(frame); + expect(result).toHaveProperty("number"); + expect(result).toHaveProperty("buffer"); + }); + + it("recovers correct enum number", () => { + const frame = exportBin(new Uint8Array([1]), DT.palette_viewer); + const result = importBin(frame); + expect(result.number).toBe(DT.palette_viewer); + expect(result.number).toBe(25); + }); + + it("recovers original payload bytes", () => { + const original = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]); + const frame = exportBin(original, DT.dwellings); + const result = importBin(frame); + const recovered = new Uint8Array(result.buffer); + expect([...recovered]).toEqual([0xDE, 0xAD, 0xBE, 0xEF]); + }); + + it("throws RangeError on buffer smaller than 8 bytes", () => { + const small = new ArrayBuffer(7); + expect(() => importBin(small)).toThrow(RangeError); + expect(() => importBin(small)).toThrow("too small"); + }); + + it("throws on CRC mismatch (corrupted payload)", () => { + const frame = exportBin(new Uint8Array([1, 2, 3]), DT.geo); + const u8 = new Uint8Array(frame); + u8[5] ^= 0xFF; // corrupt a payload byte + expect(() => importBin(frame)).toThrow("CRC32 mismatch"); + }); + + it("throws on CRC mismatch (corrupted crc bytes)", () => { + const frame = exportBin(new Uint8Array([1, 2, 3]), DT.geo); + const u8 = new Uint8Array(frame); + u8[u8.length - 1] ^= 0xFF; // corrupt last crc byte + expect(() => importBin(frame)).toThrow("CRC32 mismatch"); + }); + + it("wipeInput option zeros the source frame", () => { + const frame = exportBin(new Uint8Array([0xAA, 0xBB]), DT.geo); + const u8 = new Uint8Array(frame); + importBin(frame, { wipeInput: true }); + expect(u8.every(b => b === 0)).toBe(true); + }); + + it("wipeInput zeros frame even on CRC mismatch", () => { + const frame = exportBin(new Uint8Array([1, 2, 3]), DT.geo); + const u8 = new Uint8Array(frame); + u8[5] ^= 0xFF; // corrupt + try { importBin(frame, { wipeInput: true }); } catch (_) {} + expect(u8.every(b => b === 0)).toBe(true); + }); + + it("respects inputOffset / inputLength", () => { + const innerFrame = exportBin(new Uint8Array([0x42]), DT.palette_glade); + const padded = new Uint8Array(4 + innerFrame.byteLength + 4); + padded.set(new Uint8Array(innerFrame), 4); + const result = importBin(padded.buffer, { inputOffset: 4, inputLength: innerFrame.byteLength }); + expect(result.number).toBe(DT.palette_glade); + expect([...new Uint8Array(result.buffer)]).toEqual([0x42]); + }); +}); + +// ─── roundtrip: proto encode → exportBin → importBin → proto decode ─── + +describe("exportBin / importBin roundtrip with real proto messages", () => { + for (const { enumName, enumValue, typeName } of TYPE_MAP) { + it(`roundtrips ${typeName} (DataType.${enumName} = ${enumValue})`, () => { + const MessageType = DataProto[typeName]; + const minObj = MINIMAL_OBJECTS[typeName]; + + // 1. create & encode proto message + const protoMsg = MessageType.fromObject(minObj); + const protoBytes = MessageType.encode(protoMsg).finish(); + + // 2. wrap with bin-verify: exportBin(encodedBytes, enumValue) + const frame = exportBin(protoBytes, enumValue); + expect(frame).toBeInstanceOf(ArrayBuffer); + expect(frame.byteLength).toBe(protoBytes.length + 8); + + // 3. unwrap: importBin → { number, buffer } + const imported = importBin(frame); + expect(imported.number).toBe(enumValue); + + // 4. decode proto from recovered buffer + const decoded = MessageType.decode(new Uint8Array(imported.buffer)); + + // 5. verify decoded matches original + const originalJson = JSON.stringify(MessageType.toObject(protoMsg)); + const decodedJson = JSON.stringify(MessageType.toObject(decoded)); + expect(decodedJson).toBe(originalJson); + }); + } +}); + +// ─── CRC32 determinism ────────────────────────────────────────── + +describe("CRC32 determinism", () => { + it("same payload + same number produce identical frames", () => { + const payload = new Uint8Array([10, 20, 30, 40, 50]); + const a = new Uint8Array(exportBin(payload, DT.geo)); + const b = new Uint8Array(exportBin(payload, DT.geo)); + expect([...a]).toEqual([...b]); + }); + + it("different number produces different frame (header differs)", () => { + const payload = new Uint8Array([10, 20, 30]); + const a = new Uint8Array(exportBin(payload, DT.geo)); + const b = new Uint8Array(exportBin(payload, DT.dwellings)); + const headerA = a.slice(0, 4); + const headerB = b.slice(0, 4); + expect([...headerA]).not.toEqual([...headerB]); + }); + + it("different payload produces different CRC", () => { + const a = exportBin(new Uint8Array([1, 2, 3]), DT.geo); + const b = exportBin(new Uint8Array([1, 2, 4]), DT.geo); + const crcA = new DataView(a).getUint32(a.byteLength - 4, true); + const crcB = new DataView(b).getUint32(b.byteLength - 4, true); + expect(crcA).not.toBe(crcB); + }); +}); + +// ─── edge cases ───────────────────────────────────────────────── + +describe("edge cases", () => { + it("number=0 (DATA_UNSPECIFIED) roundtrips correctly", () => { + const payload = new Uint8Array([0xFF]); + const frame = exportBin(payload, 0); + const result = importBin(frame); + expect(result.number).toBe(0); + expect([...new Uint8Array(result.buffer)]).toEqual([0xFF]); + }); + + it("large payload roundtrips correctly", () => { + const payload = new Uint8Array(64 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i & 0xFF; + const frame = exportBin(payload, DT.geo); + const result = importBin(frame); + expect(result.number).toBe(DT.geo); + expect(new Uint8Array(result.buffer).length).toBe(payload.length); + expect([...new Uint8Array(result.buffer).slice(0, 10)]).toEqual([...payload.slice(0, 10)]); + }); + + it("accepts ArrayBuffer as input to exportBin", () => { + const ab = new Uint8Array([1, 2, 3]).buffer; + const frame = exportBin(ab, DT.geo); + const result = importBin(frame); + expect([...new Uint8Array(result.buffer)]).toEqual([1, 2, 3]); + }); + + it("importBin rejects exactly 8 zero bytes (CRC mismatch for non-trivial header)", () => { + // 8 bytes: num=0, payload=empty, stored crc=0 + // crc of empty = 0x00000000, but crc32("") in IEEE = 0x00000000 + // Actually crc32 of empty range is 0x00000000 so this should pass + const buf = new ArrayBuffer(8); + // number=0, crc=0 for empty payload → let's check + try { + const result = importBin(buf); + // If it doesn't throw, crc of empty is 0 + expect(result.number).toBe(0); + expect(result.buffer.byteLength).toBe(0); + } catch (e) { + expect(e.message).toContain("CRC32 mismatch"); + } + }); + + it("exportBin throws on invalid input type", () => { + expect(() => exportBin("not a buffer", DT.geo)).toThrow(TypeError); + }); + + it("importBin throws on invalid input type", () => { + expect(() => importBin("not a buffer")).toThrow(TypeError); + }); +}); diff --git a/tests/shared/data/data.test.js b/tests/shared/data/data.test.js index b2b8d4e..79e751a 100644 --- a/tests/shared/data/data.test.js +++ b/tests/shared/data/data.test.js @@ -9,7 +9,15 @@ import { readProp, toUint8Array, bytesToUtf8Text, + detectLegacyRootType, + describeRootType, + createTypeMismatchError, + decodeDataFromFile, + encodeDataToBytes, } from "../../../src/js/shared/data/data.js"; +import { data as DataProto } from "../../../src/js/struct/data.js"; + +const DT = DataProto.DataType; // ─── jsToProtoValue / protoValueToJs roundtrip ────────────────── @@ -240,3 +248,151 @@ describe("bytesToUtf8Text", () => { expect(bytesToUtf8Text(null)).toBe(""); }); }); + +// ─── detectLegacyRootType ────────────────────────────────────── + +describe("detectLegacyRootType", () => { + it("detects GeoObj as DataType.geo", () => { + expect(detectLegacyRootType({ type: "FeatureCollection", features: [] })).toBe(DT.geo); + }); + + it("detects DwellingsObj as DataType.dwellings", () => { + expect(detectLegacyRootType({ floors: [{}] })).toBe(DT.dwellings); + }); + + it("detects PaletteCaveObj as DataType.palette_cave", () => { + expect(detectLegacyRootType({ colors: {}, shadow: {}, strokes: {}, hatching: {} })).toBe(DT.palette_cave); + }); + + it("detects PaletteDwellingsObj as DataType.palette_dwellings", () => { + expect(detectLegacyRootType({ colors: {}, misc: {}, strokes: {} })).toBe(DT.palette_dwellings); + }); + + it("detects PaletteViewerObj by key", () => { + expect(detectLegacyRootType({ walls1: "#000" })).toBe(DT.palette_viewer); + }); + + it("detects PaletteVillageObj by key", () => { + expect(detectLegacyRootType({ roofLight: "#000" })).toBe(DT.palette_village); + }); + + it("detects PaletteGladeObj by key", () => { + expect(detectLegacyRootType({ thicket: "#000" })).toBe(DT.palette_glade); + }); + + it("detects PaletteMfcgObj by key", () => { + expect(detectLegacyRootType({ colorLight: "#000" })).toBe(DT.palette_mfcg); + }); + + it("returns null for unrecognized object", () => { + expect(detectLegacyRootType({ unknownKey: 1 })).toBeNull(); + }); + + it("returns null for null", () => { + expect(detectLegacyRootType(null)).toBeNull(); + }); + + it("returns null for non-object", () => { + expect(detectLegacyRootType("string")).toBeNull(); + }); +}); + +// ─── describeRootType ────────────────────────────────────────── + +describe("describeRootType", () => { + it("returns label and kind for DataType.geo", () => { + const d = describeRootType(DT.geo); + expect(d.label).toBe("City/Village"); + expect(d.kind).toBe("data"); + }); + + it("returns label and kind for DataType.palette_cave", () => { + const d = describeRootType(DT.palette_cave); + expect(d.label).toBe("Cave"); + expect(d.kind).toBe("styles"); + }); + + it("returns fallback for unknown type", () => { + const d = describeRootType(999); + expect(d.label).toBe("999"); + expect(d.kind).toBe("data"); + }); +}); + +// ─── createTypeMismatchError ─────────────────────────────────── + +describe("createTypeMismatchError", () => { + it("creates error for data type mismatch", () => { + const err = createTypeMismatchError(DT.geo, DT.dwellings); + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain("City/Village"); + expect(err.message).toContain("Dwellings"); + }); + + it("creates error for styles type mismatch", () => { + const err = createTypeMismatchError(DT.palette_cave, DT.palette_mfcg); + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain("Cave styles"); + expect(err.message).toContain("MFCG styles"); + }); +}); + +// ─── decodeDataFromFile with DataType ────────────────────────── + +describe("decodeDataFromFile", () => { + it("decodes JSON via legacy parser", () => { + const jsonText = JSON.stringify({ type: "FeatureCollection", features: [] }); + const bytes = new TextEncoder().encode(jsonText); + const parser = (text) => JSON.parse(text); + const result = decodeDataFromFile(DT.geo, parser, bytes); + expect(result.type).toBe("FeatureCollection"); + }); + + it("decodes proto binary for matching type", () => { + const msg = DataProto.PaletteCaveObj.fromObject({ colors: {} }); + const protoBytes = DataProto.PaletteCaveObj.encode(msg).finish(); + const result = decodeDataFromFile(DT.palette_cave, () => { throw new Error("not JSON"); }, protoBytes); + expect(result).toBeTruthy(); + }); + + it("throws on invalid data", () => { + expect(() => decodeDataFromFile(DT.geo, () => { throw new Error("not JSON"); }, new Uint8Array([0, 0, 0, 0, 0]))).toThrow(); + }); +}); + +// ─── encodeDataToBytes / decodeDataFromFile bin-verify roundtrip ─ + +describe("encodeDataToBytes / decodeDataFromFile bin-verify roundtrip", () => { + it("roundtrips proto via bin-verify frame", () => { + const msg = DataProto.PaletteCaveObj.fromObject({ colors: {} }); + const raw = DataProto.PaletteCaveObj.encode(msg).finish(); + const frame = encodeDataToBytes(DT.palette_cave, raw); + expect(frame).toBeInstanceOf(ArrayBuffer); + expect(frame.byteLength).toBe(raw.length + 8); + const decoded = decodeDataFromFile(DT.palette_cave, () => { throw new Error("not JSON"); }, frame); + expect(decoded).toBeTruthy(); + }); + + it("throws type mismatch for mismatched bin-verify frame", () => { + const msg = DataProto.PaletteCaveObj.fromObject({ colors: {} }); + const raw = DataProto.PaletteCaveObj.encode(msg).finish(); + const frame = encodeDataToBytes(DT.palette_cave, raw); + expect(() => decodeDataFromFile(DT.palette_mfcg, () => { throw new Error("not JSON"); }, frame)).toThrow("You uploaded"); + }); + + it("falls back to raw proto decode on CRC mismatch", () => { + const msg = DataProto.PaletteCaveObj.fromObject({ colors: {} }); + const raw = DataProto.PaletteCaveObj.encode(msg).finish(); + const decoded = decodeDataFromFile(DT.palette_cave, () => { throw new Error("not JSON"); }, raw); + expect(decoded).toBeTruthy(); + }); + + it("throws on corrupted bin-verify frame", () => { + const msg = DataProto.PaletteCaveObj.fromObject({ colors: {} }); + const raw = DataProto.PaletteCaveObj.encode(msg).finish(); + const frame = encodeDataToBytes(DT.palette_cave, raw); + const u8 = new Uint8Array(frame); + u8[5] ^= 0xFF; + expect(() => decodeDataFromFile(DT.palette_cave, () => { throw new Error("not JSON"); }, frame)).toThrow(); + }); +}); diff --git a/tests/shared/data/mfcg.test.js b/tests/shared/data/mfcg.test.js index faa2903..edaa2b7 100644 --- a/tests/shared/data/mfcg.test.js +++ b/tests/shared/data/mfcg.test.js @@ -161,11 +161,11 @@ describe("MFCG: import/export roundtrip", () => { // ─── paletteProtoBytesFromObj ─────────────────────────────────── describe("MFCG: paletteProtoBytesFromObj", () => { - it("returns Uint8Array", () => { + it("returns ArrayBuffer", () => { const msg = paletteObjFromLegacyJsonText(validMfcgJsonText()); const bytes = paletteProtoBytesFromObj(msg); - expect(bytes).toBeInstanceOf(Uint8Array); - expect(bytes.length).toBeGreaterThan(0); + expect(bytes).toBeInstanceOf(ArrayBuffer); + expect(bytes.byteLength).toBeGreaterThan(0); }); it("proto bytes decode back via decodePaletteFile", () => {