From 3f4a7766003fac2e082efc51c3855b67f78bb7de Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Wed, 15 Apr 2026 21:45:20 +0300 Subject: [PATCH] feat: colours and game rules editors --- package.json | 80 ++--- pnpm-lock.yaml | 42 +-- public/images/tools/col.png | Bin 0 -> 8720 bytes public/images/tools/grf.png | Bin 0 -> 8349 bytes src/components/views/ColEditorView.tsx | 395 +++++++++++++++++++++++++ src/components/views/DevtoolsView.tsx | 4 +- src/components/views/GrfEditorView.tsx | 223 ++++++++++++++ src/pages/App.tsx | 8 + src/services/ColService.ts | 79 +++++ src/services/GrfService.ts | 306 +++++++++++++++++++ src/types/col.ts | 17 ++ src/types/grf.ts | 42 +++ 12 files changed, 1130 insertions(+), 66 deletions(-) create mode 100644 public/images/tools/col.png create mode 100644 public/images/tools/grf.png create mode 100644 src/components/views/ColEditorView.tsx create mode 100644 src/components/views/GrfEditorView.tsx create mode 100644 src/services/ColService.ts create mode 100644 src/services/GrfService.ts create mode 100644 src/types/col.ts create mode 100644 src/types/grf.ts diff --git a/package.json b/package.json index 35c7a65..a17125a 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,41 @@ -{ - "name": "emerald-legacy-launcher", - "private": true, - "version": "1.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "tauri": "tauri", - "tauri:build": "tauri build && pnpm run post-build:macos", - "post-build:macos": "sh src-tauri/scripts/post-build-macos.sh", - "flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher" - }, - "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2", - "framer-motion": "^12.36.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "three": "^0.183.2" - }, - "devDependencies": { - "@types/three": "^0.183.1", - "@tailwindcss/postcss": "^4.2.1", - "@tailwindcss/vite": "^4.2.1", - "@tauri-apps/cli": "^2", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "autoprefixer": "^10.4.27", - "postcss": "^8.5.8", - "tailwindcss": "^4.2.1", - "typescript": "~5.8.3", - "vite": "^7.0.4", - "tauri-plugin-drpc": "^1.0.3", - "tauri-plugin-gamepad-api": "^0.0.5" - } -} +{ + "name": "emerald-legacy-launcher", + "private": true, + "version": "1.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:build": "tauri build && pnpm run post-build:macos", + "post-build:macos": "sh src-tauri/scripts/post-build-macos.sh", + "flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "framer-motion": "^12.36.0", + "pako": "^2.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "three": "^0.183.2" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", + "@tailwindcss/vite": "^4.2.1", + "@tauri-apps/cli": "^2", + "@types/pako": "^2.0.4", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.1", + "tauri-plugin-drpc": "^1.0.3", + "tauri-plugin-gamepad-api": "^0.0.5", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4233947..ca3fe39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: framer-motion: specifier: ^12.36.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pako: + specifier: ^2.1.0 + version: 2.1.0 react: specifier: ^19.1.0 version: 19.2.4 @@ -36,6 +39,9 @@ importers: '@tauri-apps/cli': specifier: ^2 version: 2.10.1 + '@types/pako': + specifier: ^2.0.4 + version: 2.0.4 '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -371,79 +377,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -513,28 +506,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -601,35 +590,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -675,6 +659,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -849,28 +836,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -914,6 +897,9 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1484,6 +1470,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/pako@2.0.4': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -1692,6 +1680,8 @@ snapshots: node-releases@2.0.36: {} + pako@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} diff --git a/public/images/tools/col.png b/public/images/tools/col.png new file mode 100644 index 0000000000000000000000000000000000000000..87a420d80cfacf340993df1040c50a541413582d GIT binary patch literal 8720 zcmeHLYfuwc6kaSopaVK}rqvpB8#|?=P1XPkSra5k3rq`k5GzhwaFbo)mSpqT4GAic z;yCt!&`uvz3D8cf*wIlL#v;ZF*p{?Kpfy^jXo)qIh?R#jK6s}0CKefW>I{lMYM9~d zX7Am5zCGVL=bn4seKk2gDDde(1VMrl6XH@3#2?)HBh#jUOMb}0P2l3kro_i0ds}#& za7Zajk|9V{@#90pWbivco3MsOkl^p&zn@LBi$svg>2zuumzMMjuF$B31gVizLX%nx zW+RAXnMq41vM3IfQ%ahVhW1$NLQ$HOhOS#4jzCRn6~p2tDc`d&4({P&j2|u0lbLu*+)FoSfL~JnI*!xg7-lpYg~o89 zMz6#~VzC%o6o!R`3BU{io5OH~Nx-o4Z!Qo=u?jt{nP*Fst5V*$+s7gapLZ$LH zNI=^dNTWaiDApiUz=p-f0tux4>(_vW#y@sK(G)ztegAxomI_IV{Vz+s~m;kx+ z>+kdf&l8Y|KzNPjE#N`uc>iuCO^N}T(g;#w@n_}B5QMihF;13hO7Ci`dVlU;em0%s zoUG*4y{qYW*UDm@;#93}{|-{!d+6it&DI80!SXHmwuWaSN@sT$_wLb9>5N8(QZVds6rRTJ&yuq0tXH(mHZ& zK!3)(MUmC>zhMYS2jptUd}UbQFpMlLt^ zfC!4*=a^U7sp<-;U0wCWVQ475#3961j zjbKR~JpFf*uC%%Tk22l%pHEt08e1W)S^_c~+hekvjcXGxbzj$A?;dj4PEXwelHcm` zjXEDs53^XkJ5K?ogzT2RAbi?~v)}0G>ln-^nz``8@!BS5#3wb`*?>o15IuzSF_xTp zBmI>iigs6|&j$NUdHbx;*M5Q9(e@Urqo`?P`$l(@u3%=Tk0Z#-Lp<$`<%q>3c=F1e zt`jhqd5gNr^0YaXonz{`J)a-#u^d%i^%3>(iR$g$cx9kbeWyw(1R*o%)WWUo4$!nbB7P`V62VhL8g%sH};Bi$9V@ zBPi(VzpJkM_6VTLx|SaAmz#fxLWa_=)toAEE-9_7D688&a-}t&2eu$AZ_o;kM7i1LFMW^6)0V-c30k3;X(L zz~#o@M>E5>@7DPMjRE9_BiGPXb^c&;X4f{?zbzT=NdLJ8kL!21XIJE9kGRiUYPA;* z2lnQNxif;mYB6w0vo8Rk86IQ<9#me)iCpi7-uLU&>E5G#ix}w*Nr$>(%uwEqOy2-B z%((lU`)5r>b`4%^Q^3Q0WQJr`WhZob%VUVc*O!Er9d{ew4B!3_k9ALuiM)_^AxwwE Od5JG3$L)<>TksEeEFxV1 literal 0 HcmV?d00001 diff --git a/public/images/tools/grf.png b/public/images/tools/grf.png new file mode 100644 index 0000000000000000000000000000000000000000..69c8a62b1d300a725f44a0c7da67779f0a7baf9e GIT binary patch literal 8349 zcmeHLdrVVT7{3^FfB`hdEjp09nvGOxFOM;7rBt+2X{2+7G29S$+j}XM_7;0@ZGp_9 zb2{P{Hm$PFB7Z2s#Rr=&hWH?HOx}bTV-x|ILKRUI1msb=b6Nxx#I|05_xW;;+EXg_f>bjvTM`xd*2d zk}-IA$&S`c28Zsjz$o#OYq}zS#y{`km-ra*mx#BdJSK$QFC!ZBO|#6bt+hm zAmKtoDlShU8B|6pl$5}~v*e;bsw4#dpN2*UMyIYIla;H}wPZ}XBvziDBIgnQ!U#`~ za04H_K}|9^YEY{vnr{%W>@VhnJ{-na5#d@Qi7yqc99aRL1gvC+N#$dhUa#lsL%15P z0t@2tcvxUC797k0D>!r-#oz`GMK2tAfrzB#T4gGu)KDmVAuiMC7y(PCQxg0nJSkL0 z5TP6y9zt+}f(V?06CptyA|!+e3k_NloD?KujhH9oqt;V(T9w^4A;(A+sU|6g#)7y( z5Hlg?GfGB9I?zX|m={B@Qo`@^RXC*(unZi6Ou}_4h7}QsMz4sHYbY9xq-l}~$KX#> zDI**mU>5k;-Q7JL8^Z&7axIB78m$9c47uq=u|=rmN(h20m#auTi3{ivfT}bE$yF$x zg9NONgER^RfTDG>jij7$fLe|#UIM7+umB_Du#DQb`*0jx9EipZynOy(|F2Ebpd8Z- z2$Ms)AW9gV0hA4dl!bl%Cp4*Dp;1FFJ21R7>c%E3X;i7k6(p+1X;eW{q!wpL0>v2= z%9{rIp2#cA(<2%uNK}?Kx=5j9l65jjIxIz})lg)LhDv*%LUD>1T{A*P>^TOE6UfA0 zKamIa5s-<1yhigDU=aFWud$>_QXo_6K}yX2@qiFP=9fo_B4Z7S4{iuj60+v)XvvLr zpP%6RN8Gj_VpZ&`yQ5O@u!rBJ6h9HVElSv-ldDb-F5IVj;C<#);CtJDJrcR&-7NI$ zd3zSE87P{0=*HmL%7O*HxpeoIp1Jm9{kk@#wreskq_nX4dfCRMVTYe~`(10=y7f?V zwF~gb)1KMTFX%GZW+HG1nFgyS;x-Gm&WTBINoJW3lDwoOUTs-<*_yGF7WcQ^Z{+3Q zq&}d%eBz0G%youjim>!e8sXP+;~I+VH`$t6%+d?}>3RvfXn&2_?fQDI z#U!;^FMeqh6qs_dg#$BKRd9ESxVpi177E*CnXpA%roq-Z0lhFOE-96HoieXKdWJrD zd-(h%UjEP)i=N*0ZBb{TMO1$8@vvFCIj3qwd%p;6i4nth-wv#Hh7ZJNDiF8yuMXMi z+=ae_Z!D$7JV8_5oOd+M8|v6)op~n)nvLtN{Y51Swd~f-Cx40&Tl@RE9$zlIDNdL) zdSyaOPF4S7q6>gYlee$KucMmX`ov(AuuH01%~C3CKW}zdueHZ4ZLbsFXgAt)J(dkt zYiC2}7BdLsV4YKefLBvTf$|E+My0BGIHJIG<+hcV-@(nH#l?-PHc@#)-!)ncvS;!U zn{BYS#1Ev_7NH-Ac-gMEb`|ga9csk6*_;B~?09=A3@A2srb^hxrw{VlOxpgIdb3oL z-DfeKc$P}%5JUH?E?P`oR&QZV*7yFa>cHe@$J$IM5(~lTqn1m~Bs(d)mrSUfe=RQo zlG?sAbO7&chC;%wTX60`Ol3_?jakYShW{K>Ikw3e>hUWAhmGN#c|fy!4q9mOx?^!P zi#VTH$8NoP^$O55%+qDg+zXsOC33IKE7aeT(NMhCr}2D9fl1*FBh}WHBw^?HEvR9) z4p=anb*G}fo0oqy?g($TL!a&K_snknLsiB$(Kj<8ft+W1=!I8Xfxw|Zna68xs6BS) z?!#POet53axghAD&frXq;6O>e#+U7+ ZZCXKBrO)D9yK|r$qCS*}jxYaW+ux8dmWu!Y literal 0 HcmV?d00001 diff --git a/src/components/views/ColEditorView.tsx b/src/components/views/ColEditorView.tsx new file mode 100644 index 0000000..9ea34d3 --- /dev/null +++ b/src/components/views/ColEditorView.tsx @@ -0,0 +1,395 @@ +import { useState, useRef, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; +import { ColService } from "../../services/ColService"; +import { ColFile } from "../../types/col"; +function argbToHex(argb: number) { + return (argb >>> 0).toString(16).padStart(8, '0').toUpperCase(); +} + +function hexToArgb(hex: string) { + const cleanHex = hex.replace("#", ""); + return parseInt(cleanHex, 16) >>> 0; +} + +export default function ColEditorView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled } = useConfig(); + const [col, setCol] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); + const fileInputRef = useRef(null); + const [activeTab, setActiveTab] = useState<"colors" | "worldColors">("colors"); + const showNotification = (message: string, type: "success" | "error" = "success") => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }; + + const currentColors = useMemo(() => { + if (!col) return []; + return col.colors.map((c, i) => ({ ...c, originalIdx: i })) + .filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())); + }, [col, searchTerm]); + + const currentWorldColors = useMemo(() => { + if (!col) return []; + return col.worldColors.map((c, i) => ({ ...c, originalIdx: i })) + .filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())); + }, [col, searchTerm]); + + const handleFileLoad = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + const parsedCol = ColService.readCOL(buffer); + setCol(parsedCol); + showNotification(`Loaded ${file.name}`); + } catch (err: any) { + console.error("Failed to parse COL", err); + showNotification(err.message || "Failed to parse COL", "error"); + } + e.target.value = ""; + }; + + const handleSaveCol = () => { + if (!col) return; + playPressSound(); + try { + const buffer = ColService.serializeCOL(col); + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "colours.col"; + a.click(); + URL.revokeObjectURL(url); + showNotification("COL Saved Successfully"); + } catch (err: any) { + console.error("Failed to save COL", err); + showNotification(err.message || "Failed to save COL", "error"); + } + }; + + const handleUpdateColor = (idx: number, field: string, val: string | number) => { + if (!col) return; + const newCol = { ...col, colors: [...col.colors] }; + newCol.colors[idx] = { ...newCol.colors[idx], [field]: val }; + setCol(newCol); + }; + + const handleUpdateWorldColor = (idx: number, field: string, val: string | number) => { + if (!col) return; + const newCol = { ...col, worldColors: [...col.worldColors] }; + newCol.worldColors[idx] = { ...newCol.worldColors[idx], [field]: val }; + setCol(newCol); + }; + + const handleAddColor = () => { + if (!col) return; + playPressSound(); + setCol({ + ...col, + colors: [{ name: "NewColor", color: 0xFFFFFFFF }, ...col.colors] + }); + showNotification("Color Added"); + }; + + const handleAddWorldColor = () => { + if (!col) return; + playPressSound(); + setCol({ + ...col, + worldColors: [{ name: "NewWorldColor", waterColor: 0xFFFFFFFF, underwaterColor: 0xFFFFFFFF, fogColor: 0xFFFFFFFF }, ...col.worldColors] + }); + showNotification("World Color Added"); + }; + + const handleDeleteColor = (idx: number) => { + if (!col) return; + playBackSound(); + const newCol = { ...col, colors: [...col.colors] }; + newCol.colors.splice(idx, 1); + setCol(newCol); + }; + + const handleDeleteWorldColor = (idx: number) => { + if (!col) return; + playBackSound(); + const newCol = { ...col, worldColors: [...col.worldColors] }; + newCol.worldColors.splice(idx, 1); + setCol(newCol); + }; + + return ( + + +
+
+

COL Editor

+ {col && Version: {col.version}} +
+
+ + +
+
+ + {!col ? ( +
+

Open a COL file to begin editing

+
+ ) : ( +
+
+ + +
+ +
+
+ setSearchTerm(e.target.value)} + className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + /> + +
+ +
+ + + + + {activeTab === "colors" && } + {activeTab === "worldColors" && ( + <> + + + + + )} + + + + + {activeTab === "colors" && currentColors.map((c) => ( + + + + + + ))} + + {activeTab === "worldColors" && currentWorldColors.map((w) => ( + + + + + + + + ))} + +
NameColor (ARGB)Water (ARGB)Underwater (ARGB)Fog (ARGB)Remove
+ handleUpdateColor(c.originalIdx, "name", e.target.value)} + className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm" + /> + +
+ { + const rgb = e.target.value.substring(1).toUpperCase(); + const alpha = argbToHex(c.color).substring(0, 2); + const newArgb = hexToArgb(`${alpha}${rgb}`); + handleUpdateColor(c.originalIdx, "color", newArgb); + }} + className="w-8 h-8 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent" + /> + { + const s = e.target.value.replace(/[^0-9A-Fa-f]/g, ''); + if (s.length <= 8) { + const parsed = parseInt(s, 16); + if (!isNaN(parsed)) handleUpdateColor(c.originalIdx, "color", parsed >>> 0); + } + }} + className="bg-black/40 border border-[#373737] w-24 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-sm uppercase" + maxLength={8} + /> +
+
+ +
+ handleUpdateWorldColor(w.originalIdx, "name", e.target.value)} + className="w-full bg-black/40 border border-[#373737] px-2 py-1 outline-none focus:border-[#FFFF55] text-white text-sm" + /> + +
+ { + const rgb = e.target.value.substring(1).toUpperCase(); + const alpha = argbToHex(w.waterColor).substring(0, 2); + handleUpdateWorldColor(w.originalIdx, "waterColor", hexToArgb(`${alpha}${rgb}`)); + }} + className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent" + /> + { + const s = e.target.value.replace(/[^0-9A-Fa-f]/g, ''); + if (s.length <= 8) { + const parsed = parseInt(s, 16); + if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "waterColor", parsed >>> 0); + } + }} + className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase" + maxLength={8} + /> +
+
+
+ { + const rgb = e.target.value.substring(1).toUpperCase(); + const alpha = argbToHex(w.underwaterColor).substring(0, 2); + handleUpdateWorldColor(w.originalIdx, "underwaterColor", hexToArgb(`${alpha}${rgb}`)); + }} + className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent" + /> + { + const s = e.target.value.replace(/[^0-9A-Fa-f]/g, ''); + if (s.length <= 8) { + const parsed = parseInt(s, 16); + if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "underwaterColor", parsed >>> 0); + } + }} + className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase" + maxLength={8} + /> +
+
+
+ { + const rgb = e.target.value.substring(1).toUpperCase(); + const alpha = argbToHex(w.fogColor).substring(0, 2); + handleUpdateWorldColor(w.originalIdx, "fogColor", hexToArgb(`${alpha}${rgb}`)); + }} + className="w-6 h-6 p-0 cursor-pointer shrink-0 border border-[#373737] rounded-sm bg-transparent" + /> + { + const s = e.target.value.replace(/[^0-9A-Fa-f]/g, ''); + if (s.length <= 8) { + const parsed = parseInt(s, 16); + if (!isNaN(parsed)) handleUpdateWorldColor(w.originalIdx, "fogColor", parsed >>> 0); + } + }} + className="bg-black/40 border border-[#373737] w-20 px-2 py-1 outline-none focus:border-[#FFFF55] text-white font-mono text-xs uppercase" + maxLength={8} + /> +
+
+ +
+
+
+
+ )} + +
+ +
+ + + {notification && ( + + + {notification.message} + + + )} + +
+ ); +} diff --git a/src/components/views/DevtoolsView.tsx b/src/components/views/DevtoolsView.tsx index 44b948e..622149b 100644 --- a/src/components/views/DevtoolsView.tsx +++ b/src/components/views/DevtoolsView.tsx @@ -12,7 +12,9 @@ interface DevTool { const DEV_TOOLS: DevTool[] = [ { id: "pck", name: "PCK Editor", view: "pck-editor", comingSoon: false }, { id: "arc", name: "ARC Editor", view: "arc-editor", comingSoon: false }, - { id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false } + { id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false }, + { id: "grf", name: "GRF Editor", view: "grf-editor", comingSoon: false }, + { id: "col", name: "COL Editor", view: "col-editor", comingSoon: false } ]; export default function DevtoolsView() { diff --git a/src/components/views/GrfEditorView.tsx b/src/components/views/GrfEditorView.tsx new file mode 100644 index 0000000..225d38f --- /dev/null +++ b/src/components/views/GrfEditorView.tsx @@ -0,0 +1,223 @@ +import { useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; +import { GrfService } from "../../services/GrfService"; +import { GrfFile, GrfNode } from "../../types/grf"; + +export default function GrfEditorView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled } = useConfig(); + const [grf, setGrf] = useState(null); + const [filename, setFilename] = useState("game_rules.grf"); + const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); + const fileInputRef = useRef(null); + + const [activeTab, setActiveTab] = useState<"rules" | "files">("rules"); + + const showNotification = (message: string, type: "success" | "error" = "success") => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }; + + const handleFileLoad = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + const parsedGrf = GrfService.readGRF(buffer); + setGrf(parsedGrf); + setFilename(file.name); + showNotification(`Loaded ${file.name}`); + } catch (err: any) { + console.error("Failed to parse GRF", err); + showNotification(err.message || "Failed to parse GRF", "error"); + } + e.target.value = ""; + }; + + const handleSaveGrf = () => { + if (!grf) return; + playPressSound(); + try { + const buffer = GrfService.serializeGRF(grf); + const blob = new Blob([buffer as any]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + showNotification("GRF Saved Successfully"); + } catch (err: any) { + console.error("Failed to save GRF", err); + showNotification(err.message || "Failed to save GRF", "error"); + } + }; + + return ( + + +
+
+

GRF Editor

+ {grf && editing: {filename}} +
+
+ + +
+
+ + {!grf ? ( +
+

Open a GRF file to begin editing

+
+ ) : ( +
+
+ + +
+ +
+ {activeTab === "rules" && ( +
+ {grf.root.children.map((node, i) => ( + + ))} + {grf.root.children.length === 0 && No rules found} +
+ )} + {activeTab === "files" && ( + + + + + + + + + {grf.files.length === 0 && ( + + )} + {grf.files.map((f, i) => ( + + + + + ))} + +
FilenameSize
No files in GRF
{f.filename}{(f.data.length / 1024).toFixed(2)} KB
+ )} +
+
+ )} + +
+ +
+ + + {notification && ( + + + {notification.message} + + + )} + +
+ ); +} + +function GrfNodeView({ node, level }: { node: GrfNode, level: number }) { + const [expanded, setExpanded] = useState(level < 1); + return ( +
+
setExpanded(!expanded)} + > + {node.children.length > 0 ? ( + + ) : ( +
+ )} + 0 ? "/images/Folder_Icon.png" : "/images/tools/grf.png"} + className="w-4 h-4 object-contain grayscale opacity-60" + style={{ imageRendering: "pixelated" }} + onError={(e) => (e.currentTarget.src = "/images/tools/pck.png")} + /> + {node.name} + {node.parameters.length > 0 && [{node.parameters.length} props]} +
+ {expanded && ( +
+ {node.parameters.length > 0 && ( +
+ {node.parameters.map((p, i) => ( +
+ {p.name} + {p.value} +
+ ))} +
+ )} + {node.children.map((child, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 3690ff6..cebf4fe 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -10,6 +10,8 @@ import SetupView from "../components/views/SetupView"; import PckEditorView from "../components/views/PckEditorView"; import { ArcEditorView } from "../components/views/ArcEditorView"; import LocEditorView from "../components/views/LocEditorView"; +import GrfEditorView from "../components/views/GrfEditorView"; +import ColEditorView from "../components/views/ColEditorView"; import ScreenshotsView from "../components/views/ScreenshotsView"; import SkinViewer from "../components/common/SkinViewer"; import TeamModal from "../components/modals/TeamModal"; @@ -352,6 +354,12 @@ export default function App() { {activeView === "loc-editor" && ( )} + {activeView === "grf-editor" && ( + + )} + {activeView === "col-editor" && ( + + )} {activeView === "skins" && } {activeView === "screenshots" && ( diff --git a/src/services/ColService.ts b/src/services/ColService.ts new file mode 100644 index 0000000..d998410 --- /dev/null +++ b/src/services/ColService.ts @@ -0,0 +1,79 @@ +import { ColFile, ColColor, ColWorldColor } from "../types/col"; + +export class ColService { + private static textDecoder = new TextDecoder("utf-8"); + private static textEncoder = new TextEncoder(); + public static readCOL(buffer: ArrayBuffer): ColFile { + const view = new DataView(buffer); + let offset = 0; + const version = view.getInt32(offset, false); offset += 4; + const colorCount = view.getInt32(offset, false); offset += 4; + const colors: ColColor[] = []; + for (let i = 0; i < colorCount; i++) { + const nameLength = view.getUint16(offset, false); offset += 2; + const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength)); + offset += nameLength; + const color = view.getUint32(offset, false); offset += 4; + colors.push({ name, color }); + } + + const worldColors: ColWorldColor[] = []; + if (version > 0 && offset < view.byteLength) { + const worldColorCount = view.getInt32(offset, false); offset += 4; + for (let i = 0; i < worldColorCount; i++) { + const nameLength = view.getUint16(offset, false); offset += 2; + const name = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + offset, nameLength)); + offset += nameLength; + const waterColor = view.getUint32(offset, false); offset += 4; + const underwaterColor = view.getUint32(offset, false); offset += 4; + const fogColor = view.getUint32(offset, false); offset += 4; + worldColors.push({ name, waterColor, underwaterColor, fogColor }); + } + } + + return { version, colors, worldColors }; + } + + public static serializeCOL(col: ColFile): ArrayBuffer { + let size = 8; + for (const c of col.colors) { + const nameBytes = this.textEncoder.encode(c.name); + size += 2 + nameBytes.length + 4; + } + + if (col.version > 0) { + size += 4; + for (const w of col.worldColors) { + const nameBytes = this.textEncoder.encode(w.name); + size += 2 + nameBytes.length + 12; + } + } + + const buffer = new ArrayBuffer(size); + const view = new DataView(buffer); + const out = new Uint8Array(buffer); + let offset = 0; + view.setInt32(offset, col.version, false); offset += 4; + view.setInt32(offset, col.colors.length, false); offset += 4; + for (const c of col.colors) { + const nameBytes = this.textEncoder.encode(c.name); + view.setUint16(offset, nameBytes.length, false); offset += 2; + out.set(nameBytes, offset); offset += nameBytes.length; + view.setUint32(offset, c.color, false); offset += 4; + } + + if (col.version > 0) { + view.setInt32(offset, col.worldColors.length, false); offset += 4; + for (const w of col.worldColors) { + const nameBytes = this.textEncoder.encode(w.name); + view.setUint16(offset, nameBytes.length, false); offset += 2; + out.set(nameBytes, offset); offset += nameBytes.length; + view.setUint32(offset, w.waterColor, false); offset += 4; + view.setUint32(offset, w.underwaterColor, false); offset += 4; + view.setUint32(offset, w.fogColor, false); offset += 4; + } + } + + return buffer; + } +} diff --git a/src/services/GrfService.ts b/src/services/GrfService.ts new file mode 100644 index 0000000..057cc8b --- /dev/null +++ b/src/services/GrfService.ts @@ -0,0 +1,306 @@ +import pako from "pako"; +import { + GrfCompressionLevel, + GrfCompressionType, + GrfFile, + GrfFileEntry, + GrfHeader, + GrfNode +} from "../types/grf"; + +export class GrfService { + private static textDecoder = new TextDecoder("ascii"); + private static textEncoder = new TextEncoder(); + private static rleDecode(data: Uint8Array): Uint8Array { + const output: number[] = []; + const marker = 0xff; + const maxLength = 0xfe; + let i = 0; + while (i < data.length) { + const val = data[i++]; + if (val !== marker) { + output.push(val); + } else { + if (i >= data.length) throw new Error("Invalid RLE"); + const next = data[i++]; + if (next === marker) { + output.push(marker); + } else { + const length = next; + if (length > 2 && length <= maxLength) { + if (i >= data.length) throw new Error("Invalid RLE"); + const runVal = data[i++]; + for (let j = 0; j < length + 1; j++) { + output.push(runVal); + } + } else { + output.push(length); + } + } + } + } + return new Uint8Array(output); + } + + private static rleEncode(data: Uint8Array): Uint8Array { + const output: number[] = []; + if (data.length === 0) return new Uint8Array(0); + const marker = 0xff; + const maxLength = 0xfe; + let firstRunValue = data[0]; + let runLength = 1; + const makeRun = (value: number, length: number) => { + if ((length <= 3 && value !== marker) || length <= 1) { + for (let i = 0; i < length; i++) { + if (value === marker) { + output.push(marker, marker); + } else { + output.push(value); + } + } + } else { + output.push(marker); + output.push(length - 1); + output.push(value); + } + }; + + for (let i = 1; i < data.length; i++) { + const currentValue = data[i]; + if (currentValue === firstRunValue) { + runLength++; + } else { + makeRun(firstRunValue, runLength); + firstRunValue = currentValue; + runLength = 1; + } + + if (runLength > maxLength) { + makeRun(firstRunValue, maxLength); + runLength -= maxLength; + } + } + makeRun(firstRunValue, runLength); + return new Uint8Array(output); + } + + private static readStringList(view: DataView, off: { p: number }): string[] { + const count = view.getInt32(off.p, false); + off.p += 4; + const list: string[] = []; + for (let i = 0; i < count; i++) { + const len = view.getInt16(off.p, false); + off.p += 2; + const str = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, len)); + off.p += len; + list.push(str); + } + return list; + } + + public static readGRF(buffer: ArrayBuffer): GrfFile { + const view = new DataView(buffer); + let off = { p: 0 }; + const firstWord = view.getUint16(off.p, false); + off.p += 2; + let header: GrfHeader; + if (firstWord === 0) { + off.p += 14; + header = { + compressionLevel: GrfCompressionLevel.None, + crc: 0xffffffff, + compressionType: GrfCompressionType.Unknown, + unknownData: new Uint8Array([0, 0, 0, 0]) + }; + } else { + const compressionLevel = view.getUint8(off.p++) as GrfCompressionLevel; + const crc = view.getUint32(off.p, false); + off.p += 4; + const unknownData = new Uint8Array(view.buffer, view.byteOffset + off.p, 4); + off.p += 4; + if (unknownData[3] > 0) { + throw new Error("World grf's are not currently supported."); + } + header = { + compressionLevel, + crc, + compressionType: GrfCompressionType.Zlib, + unknownData + }; + } + + let bodyData: Uint8Array; + let bodyView: DataView; + let bodyOff = { p: 0 }; + if (header.compressionLevel !== GrfCompressionLevel.None) { + view.getInt32(off.p, false); // decompressedSize + off.p += 4; + const compressedSize = view.getInt32(off.p, false); + off.p += 4; + const compressedData = new Uint8Array(view.buffer, view.byteOffset + off.p, compressedSize); + off.p += compressedSize; + let decompressed: Uint8Array; + if (header.compressionType === GrfCompressionType.Zlib || true) { + try { + decompressed = pako.inflate(compressedData); + } catch { + decompressed = pako.inflateRaw(compressedData); //neo: fallback to pako.inflateRaw if raw deflate + } + } + + if (header.compressionLevel > GrfCompressionLevel.Compressed) { + bodyData = this.rleDecode(decompressed!); + } else { + bodyData = decompressed!; + } + + bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength); + } else { + bodyData = new Uint8Array(view.buffer, view.byteOffset + off.p); + bodyView = new DataView(bodyData.buffer, bodyData.byteOffset, bodyData.byteLength); + } + + const lut = this.readStringList(bodyView, bodyOff); + const fileCount = bodyView.getInt32(bodyOff.p, false); + bodyOff.p += 4; + const files: GrfFileEntry[] = []; + for (let i = 0; i < fileCount; i++) { + const nlen = bodyView.getInt16(bodyOff.p, false); + bodyOff.p += 2; + const filename = this.textDecoder.decode(new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, nlen)); + bodyOff.p += nlen; + const size = bodyView.getInt32(bodyOff.p, false); + bodyOff.p += 4; + const data = new Uint8Array(bodyView.buffer, bodyView.byteOffset + bodyOff.p, size).slice(); + bodyOff.p += size; + files.push({ filename, data }); + } + + const root: GrfNode = { name: "__ROOT__", parameters: [], children: [] }; + this.readGameRuleHierarchy(bodyView, bodyOff, root, lut); + return { header, files, root }; + } + + private static readGameRuleHierarchy(view: DataView, off: { p: number }, parent: GrfNode, lut: string[]) { + const count = view.getInt32(off.p, false); + off.p += 4; + for (let i = 0; i < count; i++) { + const nameIdx = view.getInt32(off.p, false); + off.p += 4; + const paramCount = view.getInt32(off.p, false); + off.p += 4; + + const node: GrfNode = { + name: lut[nameIdx], + parameters: [], + children: [] + }; + + for (let j = 0; j < paramCount; j++) { + const kIdx = view.getInt32(off.p, false); + off.p += 4; + const vLen = view.getInt16(off.p, false); + off.p += 2; + const value = this.textDecoder.decode(new Uint8Array(view.buffer, view.byteOffset + off.p, vLen)); + off.p += vLen; + node.parameters.push({ name: lut[kIdx], value }); + } + + parent.children.push(node); + this.readGameRuleHierarchy(view, off, node, lut); + } + } + + private static makeString(s: string): Uint8Array { + const enc = this.textEncoder.encode(s); + const out = new Uint8Array(2 + enc.length); + new DataView(out.buffer).setInt16(0, enc.length, false); + out.set(enc, 2); + return out; + } + + public static serializeGRF(grf: GrfFile): ArrayBuffer { + const lut: string[] = []; + const buildLut = (node: GrfNode) => { + if (!lut.includes(node.name) && node.name !== "__ROOT__") lut.push(node.name); + for (const p of node.parameters) { + if (!lut.includes(p.name)) lut.push(p.name); + } + for (const c of node.children) buildLut(c); + }; + buildLut(grf.root); + let parts: Uint8Array[] = []; + const lutCount = new Uint8Array(4); + new DataView(lutCount.buffer).setInt32(0, lut.length, false); + parts.push(lutCount); + for (const s of lut) parts.push(this.makeString(s)); + const fCount = new Uint8Array(4); + new DataView(fCount.buffer).setInt32(0, grf.files.length, false); + parts.push(fCount); + for (const f of grf.files) { + parts.push(this.makeString(f.filename)); + const sz = new Uint8Array(4); + new DataView(sz.buffer).setInt32(0, f.data.length, false); + parts.push(sz); + parts.push(f.data); + } + + const writeNode = (node: GrfNode) => { + const cCount = new Uint8Array(4); + new DataView(cCount.buffer).setInt32(0, node.children.length, false); + parts.push(cCount); + for (const c of node.children) { + const nid = new Uint8Array(4); + new DataView(nid.buffer).setInt32(0, lut.indexOf(c.name), false); + parts.push(nid); + const pCount = new Uint8Array(4); + new DataView(pCount.buffer).setInt32(0, c.parameters.length, false); + parts.push(pCount); + for (const p of c.parameters) { + const pid = new Uint8Array(4); + new DataView(pid.buffer).setInt32(0, lut.indexOf(p.name), false); + parts.push(pid); + parts.push(this.makeString(p.value)); + } + + writeNode(c); + } + }; + writeNode(grf.root); + let uncompressedSize = parts.reduce((acc, p) => acc + p.length, 0); + const uncompressedData = new Uint8Array(uncompressedSize); + let offset = 0; + for (const p of parts) { + uncompressedData.set(p, offset); + offset += p.length; + } + + let bodyData = uncompressedData; + let header = grf.header; + if (header.compressionLevel >= GrfCompressionLevel.CompressedRle) { + bodyData = this.rleEncode(bodyData); + } + + let compressedSize = 0; + if (header.compressionLevel >= GrfCompressionLevel.Compressed) { + bodyData = pako.deflate(bodyData, { level: 9 }); + compressedSize = bodyData.length; + } + + const finalBuffer = new Uint8Array(10 + (header.compressionLevel >= GrfCompressionLevel.Compressed ? 8 : 0) + bodyData.length); + const fw = new DataView(finalBuffer.buffer); + let poff = 0; + fw.setInt16(poff, 1, false); poff += 2; + fw.setUint8(poff, header.compressionLevel); poff += 1; + fw.setUint32(poff, header.crc, false); poff += 4; + finalBuffer.set(header.unknownData, poff); poff += 4; + if (header.compressionLevel >= GrfCompressionLevel.Compressed) { + fw.setInt32(poff, uncompressedSize, false); poff += 4; + fw.setInt32(poff, compressedSize, false); poff += 4; + } + + finalBuffer.set(bodyData as any, poff); + + return finalBuffer.buffer; + } +} diff --git a/src/types/col.ts b/src/types/col.ts new file mode 100644 index 0000000..571f162 --- /dev/null +++ b/src/types/col.ts @@ -0,0 +1,17 @@ +export interface ColColor { + name: string; + color: number; +} + +export interface ColWorldColor { + name: string; + waterColor: number; + underwaterColor: number; + fogColor: number; +} + +export interface ColFile { + version: number; + colors: ColColor[]; + worldColors: ColWorldColor[]; +} diff --git a/src/types/grf.ts b/src/types/grf.ts new file mode 100644 index 0000000..c7f730c --- /dev/null +++ b/src/types/grf.ts @@ -0,0 +1,42 @@ +export enum GrfCompressionLevel { + None = 0, + Compressed = 1, + CompressedRle = 2, + CompressedRleCrc = 3, +} + +export enum GrfCompressionType { + Unknown = -1, + Zlib = 0, + Deflate = 1, + XMem = 2, +} + +export interface GrfHeader { + compressionLevel: GrfCompressionLevel; + crc: number; + compressionType: GrfCompressionType; + unknownData: Uint8Array; +} + +export interface GrfFileEntry { + filename: string; + data: Uint8Array; +} + +export interface GrfParameter { + name: string; + value: string; +} + +export interface GrfNode { + name: string; + parameters: GrfParameter[]; + children: GrfNode[]; +} + +export interface GrfFile { + header: GrfHeader; + files: GrfFileEntry[]; + root: GrfNode; +}