From 36a6e0fde931f189a77166aba2b6f8c479c64ba3 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sun, 1 Feb 2026 14:42:40 -0800 Subject: [PATCH] Feature/island sky color editor (#13) * Add Island tab with sky color editor - Add parseVariables() to SaveGameParser to extract variable names, values, and offsets - Add updateVariable() to SaveGameSerializer for modifying variable values - Add colorUtils.js with LEGO Island's custom HSV-to-RGB algorithm - Add SkyColorEditor component with H/S/V sliders and color preview - Add Island tab to SaveEditorPage with Sky Color section - Include reset to default button when values differ from default (56/54/68) * Remove tooltip * Add light position editor to Island tab Allows users to select one of 6 sun positions using visual globe image selectors. Includes converted globe images (BMP to WebP). --- public/globe1.webp | Bin 0 -> 2194 bytes public/globe2.webp | Bin 0 -> 2260 bytes public/globe3.webp | Bin 0 -> 2278 bytes public/globe4.webp | Bin 0 -> 2264 bytes public/globe5.webp | Bin 0 -> 2226 bytes public/globe6.webp | Bin 0 -> 2148 bytes src/core/formats/SaveGameParser.js | 24 +- src/core/formats/SaveGameSerializer.js | 52 ++++ src/core/savegame/colorUtils.js | 119 +++++++++ src/core/savegame/index.js | 16 ++ src/lib/SaveEditorPage.svelte | 43 +++- .../save-editor/LightPositionEditor.svelte | 110 +++++++++ src/lib/save-editor/SkyColorEditor.svelte | 228 ++++++++++++++++++ 13 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 public/globe1.webp create mode 100644 public/globe2.webp create mode 100644 public/globe3.webp create mode 100644 public/globe4.webp create mode 100644 public/globe5.webp create mode 100644 public/globe6.webp create mode 100644 src/core/savegame/colorUtils.js create mode 100644 src/lib/save-editor/LightPositionEditor.svelte create mode 100644 src/lib/save-editor/SkyColorEditor.svelte diff --git a/public/globe1.webp b/public/globe1.webp new file mode 100644 index 0000000000000000000000000000000000000000..cef69ca80bcf3abe3413af0ac69cafabfa9c05a0 GIT binary patch literal 2194 zcmV;D2yORLNk&GB2mkA!_rgX102Pk(7ohf(nQka(8$4`41hI0~4Vn z2o8(|Ew7koX;ozm*ej$ z`5(*H9kV!hdy-3S>XoG(5KXlfu z#t^jD)+Htk1z}p_U;$4F8vn7S3`c|`l_ne2_RdZUWuMz#9w~=ZtCnP37n=~f)%@q5 z|NIk|2xP7q|f?! z-y`}D!Hwjw%}KZ3Tipq2U>Q=M@X!CAcJ&{%|3}ZO|MLUaw`dFVFSXV6ZQ9K!?#w3U zEMb^%=0@RQWOjqA6f=$Yg=D0&QgW=0Y*s_7l%*rSGaVBvL_#_XfLoW$5<#K?@iwq) z#FE2=Aj33FNTg%}q(kLlvN5ZXKE^k3NRnV;(~QhXTjaLPm}g8a62t^&$!0MOBOWp# zXQ`O40LtN30Hn=Q8OUx)B7ECC>t6R5$ z@)9}iY6KQ>UO0@0oG8k}7!U+yrzi6*kvWeyZ4IZ)SCc+ zvduKGhg)eZ^M#RhtX(iaR=FP>>sXlx;OWr3QurUr1PQR&CE zm}Dp6AJRqZ&1*aTLgVb|v%Th-cH5b=gvzS0dW6CaMJ#&v>`Z9zp!dqw8c+8wyTBf> zjSUsSW%Z~o%7NmWdH@cv-OOI!gMFo@v$a+hVTZGDT4}iig7B+O?xFdj1F{=&A@Cf7 zuTcl}w5kuXH?-TKG1`V4$} zk@i)EfC?1wv+7j~5S}V=5U{v$0DbuCfLSY=AJBuZbWj6ZoU3i;z}}*nDY1lLoysq` zU5cHVmk^u)j@|_GZ@-$u^QpAbs5H(hD9oVtRiA5(s%;cR&7rq65)&2D2m_MPWT}q`YTdT@KA*%EsTsP%VKl+nz-#vLl_M}s7U7=6t^EGW_D}l;r zWF>MY(ZE*<4jg^&_51JMzQ&s;p1KI8Mp?t!YA9mAl}c^F^T%~j=7mkV_mR9dMcKO` z*5{LVf6QOMeR5w_TKNIC6;Nv^Knq2I*zy5JQ z@N0dhA%aGupGT%{HS*HtHr|Qty~;{J79PEpUq5;C`YrDN@&4_}JW#U0DuimUMFtw+ zT=zz%#`*F$;&GG)&Y!%-+mmm9J$|PmXa&raX=*B60nh+!rBSmRNn<5H`iu&cGN9M` zb8r6rlOJ*U$FJhkf*M4nF9_`Mi)6pm1@e+MotbM1tF%D>B#*7{P1t@lKLMhXzu9f& z{K?ftt({=|xke_>7ICt0fa0@ClK0xKlWj2f`PCelstZiXtyZ;#t`tC2&?rjeeYV+k zOq^I3SzaEFU3mY?mh5cq1*G2wRGPjb^i-KZSD|V^=&BZ;Cb1eg@m|KcE&JHs_57Ay zEx+R2Z#}4L?UP#_P(|8(Ru^TFtebQee=~G!j?%m+kNxiU$<`0PX2-yYb>(}2s#L&8 zH#4JZT|u$Trv@=`O_H+jnlvwu2T&Tn`r4a&?L1O7mXYUg5h!0k4KFfvQzVKE#m00D zh!Rn93|hZqtq*!_(5e87t@0=5l~3_%8aqLx&NI7dOgH{T?{w}SG-=k8BmB09ULz-1 zo~t@qb~^AeV8H)0XH8 zJfAD`d4+9cwxKVe{JtBRt9Ozu>V3ynIHi6G-jIREW>q6s_k+MnC-GNe%eJq zXCL9tgXO78XR%1)K(lt{AJNc0GY?MV za@{!xIWXK8;;v&;W}CH%)5P{`$VmVeh#789S1^}1PK8Vv>Fz~@32eLx9v zA#Y;bte2ea`(lkPtkq_6{#P`HK zQQq{L<*wtS6<4B<7d5eS1>DS%m||WsG0~7Z>{kvQBP)Y2kwjKCRQ($?u3&(U2owT$ zi^u?~F$AK^oqT^>g+pU-DMpLvY#UO{M_vy)9CvDOM*)ofJHTxqIP$yoFhqxh+fD}% Uzz7?dWn=;41n!DQ#)jAx09eB~-2eap literal 0 HcmV?d00001 diff --git a/public/globe2.webp b/public/globe2.webp new file mode 100644 index 0000000000000000000000000000000000000000..5bafe2c99df25dce80ff5a1469d65881185c9569 GIT binary patch literal 2260 zcmV;_2rKteNk&G@2mkA!KjSt4z@)+5{R6mz@2QO4TsPd} zdBc%x+orR>ZU2}+S(w?43$j390aPpvODCXWeCPt4aASrDJzmy;JUk{jigE++xbkm-%?g@7rv zp$X)a%gF!#FU%YOnEH9&Bl_<^lGM1704-;HNs+$r(Lbhtyu*e3O@`gB;BOi~{ySCa z`)}{JcIXGqpXx`qcIh{J<*eD{92Y%W(5W@Eo45X?GIgx(u>8_>=6c zV{wHjz@P(-Np@XE*lXFXA>m^78{ z%6;l2oLyW3APuRmOcCVA!q+Vha+c5(7U%2gkAaR<00OWqn&V#cY!g&4($c=(=7U_PE%X#RP10nwi>o7`Wz1%sW@c5|s`D7h z$Y-e?z)r*!AwjCcPV-Ue@KeF7Y75Wjs44a8PY5I>nPS%U3z@^M*RK5m08bf=%BQ;t z!5OPnqMHses~ld*?@6 zy&CK}2DhJo7BOaKQaLMAu8oz5zO<5Y^z}NSQ>FMrPDH5>-3SaL^2^^pJBjdRM5~dM zxg<>tKmK8zu3CkK&elYJj6~=dBD$?I6Y=+xsMVkGgftmcpp5e4uhwZo>P8B52(Yqf z=*T~b0yXv{Wtu<*Y8?2dkx_v_7<;ibyOzy_NRyQ?Tfe_oi%7tW82jrWUZ0$2V>JSZ z6Qjn)vdNUUMk;2gxhVd9bjPB!+I~xY^<7bto&^u z*IsidW~NT0>Zr1TL(=j7%f8UUg>mGBvCUDIuq$~091d&8PbNRk7jG660SrygE6HTTHc%kS02<#`1qGOQplH z*&87IruL^#9xm^FKY0?oS}Ym62GGT>&8Z>OX|&c>uZm;2=IaiX+nPgowmn+jTg)F~ z!n?ox_A_v96uyj1YoIx^g49)ZwY6d=tpTXXE98y+>BBqUPaZy5PJX<*n7;ugYTTUI z<^;u3N1f7?O>4Exbkc@$B&Sc7_s+q|{O$uAH{sc{%qk=qkuf8#;i~4!A_v3NxhcdD zm=5T1`S3pP-6bg!!`thgf59^fwC{B-ZlV)<>vXY1H1 z${<$wDykAXf6k8GTBg&)5&cW9@rF;&Uq0c(<@w^l1ZIBsJa%a?r^>ADAhra?>Mm03 zSi{mJg0?kDkM&f{ERe6hUvh%^j}xPsNX+e1P9G0?afj$2^ru1<9YL3NZx{sHUuNX!E z5R>b(EYYKeIh$J~DZwl=+JRCw-nc%T)*fEY7t0CM{&Jx_xYoph7pgT<^ZYE^1R|O( z<6%*yWP-g59$C}7!C~#+*q=`3z=Ss4V-u+c@o;dUpsP*BoS9(CqYK&OMl(ruZqd}M zLBhMSPj$!o)XV=NB)?WbGpw8NG|Mxalh4F|Xj||gFDtRf+ziFs@}?wSPcdKInaAXs zARDUfunuiAmOH-1Rv;K|%1Se(9_`gWtq;@5s|n_Kg^-7$AL0F)dk4cND=jxO!L5c{ zB*Wa4M%#pK?a{Zj@AA$ZIObKpb4m1fE?mA~+mXtOpxC(GYAd9r@n)Eps4RHCR}UE8 zRr44?c!}iuBrej@+xXy8c(KXk-kmLC2fW#AtWlHOLCxly^HrkFd7%@gI%TD^=i5ts z>%yg@Fx!mjcK;pRMKhI?h0!drMbqB%Cuzczq-L3I-+kxeQP^zc`9X4r04UBu<03Cj zrHN5tDrnML#VMpR70P0ZBOGowcCie=Mjs=`d@ZA~&rQ>R3EhWvwkspS1=1 zz<~(oxM1s;mo;J6Nl+r9oBES@Q@BW~tjQXN$L`U$@+IoVlF*w(@wJF9){-4+ch=~H iI~d50yI@bA!XX7}MZB=>y*}dOg_GkhWiCDB` z!rI-x-!$CDwyi4ryW{Q-Aq~(rMUXONp1HeT*R^e%&UyFT^XwNp1!ScA^x*v2w(a?k z5HW&mhy#Zp0GS{g&Vd7v1JN+OzhJbOo7`O`?WHg;gOnT4xmUmY&G&XisTT;7@I!tH z;4xDkzG>Pdk}m$p^G$U`bf+#Xj96TEySh z(OGd>_{~05?PB1xyW1#9yfae0o}$=a_p|BP!1FM*?zjB%1&ir~!Me6>Zrest14u5x zVUbOy7ZE+{-fg<>3T_})NPsP1c7=h967KmQz~Gw!)Be9-ME@N~k{UN$S|O0}k;LQ) zum33gYR6)J$D_RZ1BCx>;E%H3pP%3Cz?1A(@$Ahm{I=81v!#oH*UtJXPn(%4$>wk0 z{7Q?-($#7NjCrir;G&uRN}Xp4xu8Y%J(>r%-%$z1oz5;L)eT6VNAf-2g$M0?NF{x5 znRPiku<5En`T>V6%P0gh%Yda%PNeU3Ms;F>{6K~gBSsXPI0C>@g_3JAF`5qSxgLC} zRlEg*4XlTT9LV;rOm;C)*o6$jm?$at7X|}hVhy2D7_jd)iyh3wg^!S905sRcFX#cZ zB?6@HW{XZUU-ko*)T39dGpRsN)zZU2X66mR$XpW9dN_u@*5v?+-neSJEJti zV8p%>e{Pb7D99BG?Lz>TkZk)M@E6UX#f?-FW`<{R9<-}e&_6mviX$PL9?R_x!r(Y; zGL(^83=GTf4H-b}DmucUEm>sP-gpT!-f|2-5_yTD;TWdnhWfxSz@BvoGURe?uN%2# z?e51MA%-uSvLM{V3^e*YA|tm~IJO8nOvl%*7bww4RTKb3Xe4qJl*{7BJO(0t`VZqr zsimV-`SSwb-@JaM;O`5a^$H{gqWo_j#inVaN8<&@hX7jo>lGpM0ynSd=m%T@WW#;I zfL#`O)GlPymiLZUDF5CglsBE>+o%6+R;ZUFixgFen=v=3A2 zhE*W9(JS+9F=&D4p=gPTwQ@2dlE=h^2E*(9gBGNKRa`-7GlcBf%#4A^uAom*(>uyy z`XToh2#n`s8sbb^ zwikn#aceGztI)W}Sr6k|K5-@H>T#2|<48wVr%AQBwMuF{{HUlQOX94^qd1HLZ$V7f zCW1~qMc5>iqiKHN)jz_4t7 zerr<=ljpad{GT5`Tvhc4SUJOPPlD7!pgb*tlT0DaD{Pb4+ z{Ml*p_*q@?s!F($?^sb3BV_gm*<6LaZh7p>n8Py0b1RV!ug+@+ zLY@_6q4Y0-RHNaIci{&l1#Sv}$mbYY7^2=dLuXj69G1zday|%@D9Rt=$je;cU4CkK zZB|1_gj?Fj;b%OuQvUn`m7)==JZ8D7xmD!vnmFub+;@e_W~MT4BoRNQy!h<%VLCce znm8YZqok#3mDElttTFN?k|*yx?M;t0=hO>igg7|ZBLfZ&)#P*Ku8<6=Nmlj6htfKn zh<9PIYyekWZk1{K@rE-mWR4?t*K|`HSv<0b7ynUfNp)IZB-KVh;xNs|XU(ATy}mc2 z$I@daOGzM34${4^)dtom9sN8)vN_#UykcflrO3vIDX-u8K`2enq86;g^#cwg@S1#K z9{;=;ITs_Y@Zmz|*)jGH((zO@zKf;uQKKPdOkBv2Rl-p#Q*l;qPAl;E^92BWvA`wf zulLe}$t+{J?CWpddI%BW`ZADVv^?MxA^Vn;z$M!>Mh6hwnosLtS|9bpB&G|&9d*Bv;)q^Zr}F;4t<2?lFj*>XKKhLN1E zE>+t?*?|k`x$7@4tx02srQWDz^PTinYmLrD*67XKa?MM3Kw2!VshrMt342APGilOj zeVsy{3$4L)#pJRfqQk2}BLL>pjLFu9mbAfmX6TxX$N)PWZxrtQ<6WIRguekNp?nIU(J{x!D4nhCuE0D`G; AiU0rr literal 0 HcmV?d00001 diff --git a/public/globe4.webp b/public/globe4.webp new file mode 100644 index 0000000000000000000000000000000000000000..12272ab4ac00db654ddceb8b791227d2f4e48bbf GIT binary patch literal 2264 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 AApigX literal 0 HcmV?d00001 diff --git a/public/globe6.webp b/public/globe6.webp new file mode 100644 index 0000000000000000000000000000000000000000..6f83bd2cae4f9f6c8495e0d7ab4491cb27ffb779 GIT binary patch literal 2148 zcmV-q2%Gm(Nk&Fo2mkA!@8YVqZKXQsRotZqUj!ftDQN&5 z_|)gFr-tL&w$r8%MD-6%6O&cyPm&IZ6>OE55vCaUy~qV&nI=VXpy`~Uxss;dN^-RJX+ z=)VI=Qsah8D+E|RBjpKC|2z1J_1|b(|JHJc%=kZ-`{X}-C0mF7AM>*Vcw63xm(LF2 z`ChvyH!(-ap5Cynw<#C%XE(K5i8xH?#5u8Y8WwDCsMb4jJtPSymLm=mIG|kIOx^P& z5_uS;OsHZ|UJ=2Ya(NSN8%v=wGAF4Dk(Uubtl|+tM$7rZSdJmhsB+GT02d3wwX=H;5)^rslzUAXi#pa*1MklcWfw^2a*uA~ ztZ>kK=Hf8#&-R;r0Yx4y+ABtBVNq?b8wQ(olotjZ>Zq*%KvqeTIgZ+?A@@k1gEGzQ zLzJYDSq2oTY(!inlwxv)-|eCAX~jW$P*a}?9I14MylgRx%wBUT9Q1p@uBTP#Q`LJA zWzZ~7brfW^aV=c)N~*X%*jNGvf%0`(KYWZTOCJrG?VW__|{sy@Hh-mH}%gF+`?an~y;a~p{7NRQ*!#1rUe7!zy-_vL1h3cRK^ z!T`g8=Tn1VqkM}ZO}1ZPL*)uR(fMF2j3V(uYpOeelJ@%3m&qr7tlLakv~=s# z(BGzlblA{i8IUS$KwUz%SD$Fizdl`B^G?fez72$Mq1V}=OITkR80EAPFLgB3Y+&)& zAYNa76z1Jn{t_-T(IrV7b$}hvv+dCLs2c>PqqI%46b9p~%L(w?r%TZ}k)M7$AY))X z=-Rwhpes^;;HcD$bQJ0)AUVeID=v{=6wE}>|2Q+sf_|KD1 zzkZ&K1ty<6zryk-7-ZV%?$GPNbas7Rs%aoTnT^q~0hArMw)nSNl9D=Slovv9Dm&-$ zg9q#8fI4g#l1-qgUXK5G`DsE2Q=3S!Vy!Yq)*bw=vlAaGU0&Gczr+{@>h)}88&~sqUm#D<@qy`5-g%r!1QP*`MC%vb;woK_okut{HlrcAk_Zr z@uh7iBATXx11qt#W7h46uE2!1*44D~datG)D_RrwcjVQv)5;IFvtePGWVxCZdE7ga z2&8c@U+S=0X|Q71=u)%ad&SA|SnyD;2Hcos6lY>^)7^D`7$yR zs<&6;V_PM1mNZTvxEiIIG9X_Ie8F>ER2Qq4<%*TqHBOgvIvqMnR`IPgCyPzbNHtwe$-i@Lh+j!u z@SMK@N9U`PGE!-5E3^v$a$d$%DRZuSN8Kt+W<{Px%x6GWFw3LU^*MibcFQW-#6!E4 zakMR%6l-@|5;!gCto;cRB$+df*5A)BmfLa@iG`|A(Y8tz4-=#rb7GLBd6tufu`F_x zXDUOo#*!;Yor?%M$Vr$lwlPPdOtx8RK~ z(l}1n*ogh$FrmncR*U%o`b0=;fO8BcXxWv*G39pA@&L4|kamX^+Ku)omOPZE)Xsa* zp0!_|v + variablesEndOffset: null // Where END_OF_VARIABLES marker ends }; } @@ -70,20 +72,30 @@ export class SaveGameParser { } /** - * Skip over the variables section + * Parse the variables section, storing name/value pairs with their offsets * Must be called after parseHeader() */ - skipVariables() { + parseVariables() { while (true) { + const nameOffset = this.reader.tell(); const nameLength = this.reader.readU8(); const name = this.reader.readString(nameLength); if (name === 'END_OF_VARIABLES') { + this.parsed.variablesEndOffset = this.reader.tell(); break; } + const valueOffset = this.reader.tell(); const valueLength = this.reader.readU8(); - this.reader.skip(valueLength); + const value = this.reader.readString(valueLength); + + this.parsed.variables.set(name, { + value, + nameOffset, + valueOffset, + valueLength + }); } } @@ -371,11 +383,11 @@ export class SaveGameParser { /** * Full parse for the save editor (header + missions) - * @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }} + * @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[], variables: Map }} */ parse() { this.parseHeader(); - this.skipVariables(); + this.parseVariables(); this.skipCharacters(); this.skipPlants(); this.skipBuildings(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index 44639b1..71d8b5c 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -285,6 +285,58 @@ export class SaveGameSerializer { return buffer; } + /** + * Update a variable value in the save file + * @param {string} name - Variable name + * @param {string} newValue - New value string + * @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations) + * @returns {ArrayBuffer|null} - Modified buffer or null on error + */ + updateVariable(name, newValue, buffer = null) { + const varInfo = this.parsed.variables.get(name); + if (!varInfo) { + console.error(`Variable not found: ${name}`); + return null; + } + + const workingBuffer = buffer || this.createCopy(); + const array = new Uint8Array(workingBuffer); + const encoder = new TextEncoder(); + + const oldLength = varInfo.valueLength; + const newLength = newValue.length; + + if (oldLength === newLength) { + // In-place update - same length, just replace bytes + const valueBytes = encoder.encode(newValue); + array.set(valueBytes, varInfo.valueOffset + 1); // +1 for length byte + return workingBuffer; + } else { + // Different length - need to rebuild buffer + const oldArray = new Uint8Array(workingBuffer); + const sizeDiff = newLength - oldLength; + const newBuffer = new ArrayBuffer(workingBuffer.byteLength + sizeDiff); + const newArray = new Uint8Array(newBuffer); + + // Copy everything up to the value (including the length byte position) + const valueDataStart = varInfo.valueOffset + 1; // After length byte + newArray.set(oldArray.slice(0, varInfo.valueOffset)); + + // Write new length byte + newArray[varInfo.valueOffset] = newLength; + + // Write new value + const valueBytes = encoder.encode(newValue); + newArray.set(valueBytes, valueDataStart); + + // Copy everything after the old value + const afterOldValue = valueDataStart + oldLength; + newArray.set(oldArray.slice(afterOldValue), valueDataStart + newLength); + + return newBuffer; + } + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/savegame/colorUtils.js b/src/core/savegame/colorUtils.js new file mode 100644 index 0000000..089453d --- /dev/null +++ b/src/core/savegame/colorUtils.js @@ -0,0 +1,119 @@ +/** + * Color utilities for LEGO Island's custom HSV-based background color + * + * IMPORTANT: LEGO Island uses a NON-STANDARD HSV algorithm (see legoutils.cpp) + * - H (Hue): 0-100 maps to standard hue wheel + * - S (Saturation): NOT standard saturation! S=100 produces WHITE + * - V (Value): Brightness component + * + * For vivid colors, keep S in the 40-70 range. The default sky is "set 56 54 68". + */ + +/** + * Parse a backgroundcolor variable value into HSV components + * @param {string} value - Value in format "set H S V" + * @returns {{ h: number, s: number, v: number }} HSV values (0-100) + */ +export function parseBackgroundColor(value) { + const match = value.match(/^set\s+(\d+)\s+(\d+)\s+(\d+)$/); + if (!match) { + // Default sky color + return { h: 56, s: 54, v: 68 }; + } + return { + h: parseInt(match[1], 10), + s: parseInt(match[2], 10), + v: parseInt(match[3], 10) + }; +} + +/** + * Format HSV values into a backgroundcolor variable value + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {string} Value in format "set H S V" + */ +export function formatBackgroundColor(h, s, v) { + return `set ${Math.round(h)} ${Math.round(s)} ${Math.round(v)}`; +} + +/** + * Convert LEGO Island's custom HSV (0-100 scale) to RGB (0-255) + * Replicates the exact algorithm from legoutils.cpp ConvertHSVToRGB() + * + * WARNING: S=100 always produces white due to the algorithm! + * + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {{ r: number, g: number, b: number }} RGB values (0-255) + */ +export function hsvToRgb(h, s, v) { + // Convert 0-100 scale to 0-1 (as the game does with * 0.01) + const hNorm = h / 100; + const sNorm = s / 100; + const vNorm = v / 100; + + // LEGO Island's custom algorithm (from legoutils.cpp ConvertHSVToRGB) + let max; + if (sNorm > 0.5) { + max = (1.0 - vNorm) * sNorm + vNorm; + } else { + max = (vNorm + 1.0) * sNorm; + } + + if (max <= 0) { + return { r: 0, g: 0, b: 0 }; + } + + const min = sNorm * 2.0 - max; + const hueSegment = Math.floor(hNorm * 6); + const hueFraction = hNorm * 6.0 - hueSegment; + const delta = hueFraction * ((max - min) / max) * max; + const ascending = min + delta; // Channel value rising from min + const descending = max - delta; // Channel value falling from max + + let r, g, b; + switch (hueSegment) { + case 0: // Red to Yellow + r = max; g = ascending; b = min; + break; + case 1: // Yellow to Green + r = descending; g = max; b = min; + break; + case 2: // Green to Cyan + r = min; g = max; b = ascending; + break; + case 3: // Cyan to Blue + r = min; g = descending; b = max; + break; + case 4: // Blue to Magenta + r = ascending; g = min; b = max; + break; + case 5: // Magenta to Red + case 6: + r = max; g = min; b = descending; + break; + default: + r = 0; g = 0; b = 0; + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; +} + +/** + * Convert HSV (0-100 scale) to hex color string + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {string} Hex color string (e.g., "#ffcc00") + */ +export function hsvToHex(h, s, v) { + const { r, g, b } = hsvToRgb(h, s, v); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 2bab6d1..a17593e 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -11,6 +11,7 @@ export { SaveGameSerializer, createSerializer } from '../formats/index.js'; export { PlayersParser, parsePlayers } from '../formats/index.js'; export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js'; export * from './constants.js'; +export * from './colorUtils.js'; // Import dependencies import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js'; @@ -24,6 +25,7 @@ import { getSaveFileName, PLAYERS_FILE, Actor, ActorNames } from './constants.js * @property {string} fileName - Save file name * @property {Object|null} header - Parsed header data * @property {Object|null} missions - Mission scores + * @property {Map|null} variables - Parsed variables (name -> { value, nameOffset, valueOffset, valueLength }) * @property {string|null} playerName - Player name from Players.gsi * @property {ArrayBuffer|null} buffer - Raw file buffer (for editing) */ @@ -87,6 +89,7 @@ export async function listSaveSlots() { fileName, header: null, missions: null, + variables: null, playerName: null, buffer: null }; @@ -98,6 +101,7 @@ export async function listSaveSlots() { const parsed = parseSaveGame(buffer); slot.header = parsed.header; slot.missions = parsed.missions; + slot.variables = parsed.variables; slot.buffer = buffer; // Try to get player name @@ -160,6 +164,7 @@ export async function loadSaveSlot(slotNumber) { fileName, header: parsed.header, missions: parsed.missions, + variables: parsed.variables, playerName, buffer }; @@ -219,6 +224,17 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply variable update + if (updates.variable) { + const { name, value } = updates.variable; + const varSerializer = createSerializer(newBuffer); + const result = varSerializer.updateVariable(name, value); + if (result) { + newBuffer = result; + modified = true; + } + } + // Only save if something was actually modified if (!modified) { return slot; diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index c5faa00..d571586 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -3,6 +3,8 @@ import BackButton from './BackButton.svelte'; import Carousel from './Carousel.svelte'; import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte'; + import SkyColorEditor from './save-editor/SkyColorEditor.svelte'; + import LightPositionEditor from './save-editor/LightPositionEditor.svelte'; import { saveEditorState, currentPage } from '../stores.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { Actor, ActorNames } from '../core/savegame/constants.js'; @@ -18,7 +20,8 @@ const saveTabs = [ { id: 'player', label: 'Player', firstSection: 'name' }, - { id: 'scores', label: 'Scores', firstSection: null } + { id: 'scores', label: 'Scores', firstSection: null }, + { id: 'island', label: 'Island', firstSection: 'skycolor' } ]; // Reset state when navigating to this page @@ -107,6 +110,23 @@ } } + async function handleVariableUpdate(update) { + if (selectedSlot === null) return; + + try { + const updated = await updateSaveSlot(selectedSlot, update); + if (updated) { + slots = slots.map(s => + s.slotNumber === selectedSlot + ? { ...s, variables: updated.variables } + : s + ); + } + } catch (e) { + console.error('Failed to update variable:', e); + } + } + // Tab/section functions function switchTab(tab) { activeTab = tab.id; @@ -354,6 +374,27 @@ onUpdate={handleMissionUpdate} /> + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
{/if} diff --git a/src/lib/save-editor/LightPositionEditor.svelte b/src/lib/save-editor/LightPositionEditor.svelte new file mode 100644 index 0000000..8c0941b --- /dev/null +++ b/src/lib/save-editor/LightPositionEditor.svelte @@ -0,0 +1,110 @@ + + +
+
+ {#each positions as position} + + {/each} +
+ {#if !isDefault} + + {/if} +
+ + diff --git a/src/lib/save-editor/SkyColorEditor.svelte b/src/lib/save-editor/SkyColorEditor.svelte new file mode 100644 index 0000000..c9c73cf --- /dev/null +++ b/src/lib/save-editor/SkyColorEditor.svelte @@ -0,0 +1,228 @@ + + +
+
+
+
+ + + +
+
+ {#if !isDefault} + + {/if} +
+ +