|
| 1 | +import { useState, useCallback } from "react"; |
| 2 | +import { md5 } from "@/lib/md5"; |
| 3 | +import { useTheme } from "@/hooks/useTheme"; |
| 4 | + |
| 5 | +const MAP_COLORS: number[] = [ |
| 6 | + 0xff000000, 0xff000000, 0xff000000, 0xff000000, |
| 7 | + -10912473, -9594576, -8408520, -12362211, -5331853, -2766452, -530013, -8225962, -7566196, -5526613, |
| 8 | + -3684409, -9868951, -4980736, -2359296, -65536, -7929856, -9408332, -7697700, -6250241, -11250553, -9079435, -7303024, -5789785, -10987432, |
| 9 | + -16754944, -16750080, -16745472, -16760576, -4934476, -2302756, -1, -7895161, -9210239, -7499618, -5986120, -11118495, -9810890, -8233406, -6853299, |
| 10 | + -11585240, -11579569, -10461088, -9408400, -12895429, -13816396, -13158436, -12566273, -14605945, -10202062, -8690114, -7375032, -11845850, |
| 11 | + -4935252, -2303533, -779, -7895679, -6792924, -4559572, -2588877, -9288933, -8571496, -6733382, -5092136, -10606478, -12030824, -10976070, |
| 12 | + -10053160, -13217422, -6184668, -3816148, -1710797, -8816357, -10907631, -9588715, -8401895, -12358643, -5613196, -3117682, -884827, -8371369, |
| 13 | + -13290187, -12500671, -11776948, -14145496, -9671572, -8092540, -6710887, -11447983, -13280916, -12489340, -11763815, -14138543, -10933123, |
| 14 | + -9619815, -8437838, -12377762, -14404227, -13876839, -13415246, -14997410, -12045020, -10993364, -10073037, -13228005, -12035804, -10982100, |
| 15 | + -10059981, -13221093, -9690076, -8115156, -6737101, -11461861, -15658735, -15395563, -15132391, -15921907, -5199818, -2634430, -332211, -8094168, |
| 16 | + -12543338, -11551561, -10691627, -13601936, -13346124, -12620068, -11894529, -14204025, -16738008, -16729294, -16721606, -16748002, -10798046, |
| 17 | + -9483734, -8301007, -12309223, -11599616, -10485504, -9436672, -12910336, -7111567, -4941686, -3034719, -9544363, -9422567, -7780833, -6335964, |
| 18 | + -11261165, -9880244, -8369315, -6989972, -11653575, -11580319, -10461833, -9409398, -12895927, -8168167, -6262241, -4553436, -10336749, -12037595, |
| 19 | + -10984403, -9997003, -13222628, -9423305, -7716285, -6271666, -11261911, -14148584, -13556962, -13031133, -14805742, -10532027, -9151404, -7902366, |
| 20 | + -12109773, -12763072, -11841713, -11051940, -13750224, -11128002, -9879989, -8763048, -12573138, -13292736, -12503729, -11780516, -14147536, |
| 21 | + -13294824, -12506338, -11783645, -14149102, -13289187, -12499420, -11775446, -14144746, -10212832, -8768729, -7455698, -11854056, -15069429, |
| 22 | + -14740979, -14346736, -15529208, -8052446, -6084310, -4378575, -10217191, -9950140, -8440237, -7061663, -11656909, -12578540, -11594471, -10741475, |
| 23 | + -13628145, -15771554, -15569805, -15303034, -16039354, -14130078, -13469064, -12939636, -14791862, -12837077, -11918027, -11129794, -13822176, |
| 24 | + -15827107, -15623310, -15420283, -16097466, -12171706, -11119018, -10197916, -13355980, -6784153, -4548994, -2576493, -9282483, -10914455, -9596799, |
| 25 | + -8411242, -12363697 |
| 26 | +]; |
| 27 | + |
| 28 | +const MAP_COLORS_REVERSE = new Map<number, number>(); |
| 29 | +MAP_COLORS.forEach((color, idx) => MAP_COLORS_REVERSE.set(color, idx)); |
| 30 | +MAP_COLORS_REVERSE.set(0, 0); |
| 31 | +MAP_COLORS_REVERSE.set(0xff000000 | 0, 0); |
| 32 | + |
| 33 | +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
| 34 | + |
| 35 | +const bytesToHex = (bytes: Uint8Array): string => |
| 36 | + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); |
| 37 | + |
| 38 | +const formatUUID = (hex: string) => |
| 39 | + `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; |
| 40 | + |
| 41 | +const uuidsToBytes = (uuidList: string[]): Uint8Array => { |
| 42 | + const bytes = new Uint8Array(uuidList.length * 16); |
| 43 | + uuidList.forEach((uuid, i) => { |
| 44 | + const hex = uuid.replace(/-/g, ""); |
| 45 | + for (let j = 0; j < 16; j++) |
| 46 | + bytes[i * 16 + j] = parseInt(hex.slice(j * 2, j * 2 + 2), 16); |
| 47 | + }); |
| 48 | + return bytes; |
| 49 | +}; |
| 50 | + |
| 51 | +const parseUUIDs = (buffer: ArrayBuffer): string[] => { |
| 52 | + const bytes = new Uint8Array(buffer); |
| 53 | + const result: string[] = []; |
| 54 | + for (let i = 0; i + 16 <= bytes.length; i += 16) |
| 55 | + result.push(formatUUID(bytesToHex(bytes.slice(i, i + 16)))); |
| 56 | + return result; |
| 57 | +}; |
| 58 | + |
| 59 | +const nameUUIDFromBytes = (bytes: Uint8Array): string => { |
| 60 | + const hash = md5(bytes); |
| 61 | + hash[6] = (hash[6] & 0x0f) | 0x30; |
| 62 | + hash[8] = (hash[8] & 0x3f) | 0x80; |
| 63 | + return formatUUID(bytesToHex(hash)); |
| 64 | +}; |
| 65 | + |
| 66 | +const setLockedBit = (uuid: string): string => { |
| 67 | + const [first, ...rest] = uuid.split("-"); |
| 68 | + const byte0 = parseInt(first.slice(0, 2), 16) | 0x01; |
| 69 | + return byte0.toString(16).padStart(2, "0") + first.slice(2) + "-" + rest.join("-"); |
| 70 | +}; |
| 71 | + |
| 72 | +const parseImageUUIDs = (img: HTMLImageElement): string[] => { |
| 73 | + const canvas = document.createElement("canvas"); |
| 74 | + canvas.width = img.width; |
| 75 | + canvas.height = img.height; |
| 76 | + const ctx = canvas.getContext("2d")!; |
| 77 | + ctx.drawImage(img, 0, 0); |
| 78 | + |
| 79 | + if (img.width % 128 || img.height % 128) |
| 80 | + throw new Error(`Dimensions must be divisible by 128. Got ${img.width}×${img.height}`); |
| 81 | + |
| 82 | + const results: string[] = []; |
| 83 | + for (let by = 0; by < img.height / 128; by++) { |
| 84 | + for (let bx = 0; bx < img.width / 128; bx++) { |
| 85 | + const { data } = ctx.getImageData(bx * 128, by * 128, 128, 128); |
| 86 | + const colors = new Uint8Array(128 * 128); |
| 87 | + for (let i = 0; i < 128 * 128; i++) { |
| 88 | + const off = i * 4; |
| 89 | + const argb = ((data[off + 3] << 24) | (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]) | 0; |
| 90 | + const colorByte = MAP_COLORS_REVERSE.get(argb); |
| 91 | + if (colorByte === undefined) { |
| 92 | + const x = bx * 128 + (i % 128), y = by * 128 + ((i / 128) | 0); |
| 93 | + throw new Error(`Unsupported color at (${x}, ${y}): ARGB=${argb}`); |
| 94 | + } |
| 95 | + colors[i] = colorByte; |
| 96 | + } |
| 97 | + results.push(setLockedBit(nameUUIDFromBytes(colors))); |
| 98 | + } |
| 99 | + } |
| 100 | + return results; |
| 101 | +}; |
| 102 | + |
| 103 | +export default function Index() { |
| 104 | + const { resolved, toggle } = useTheme(); |
| 105 | + const [uuids, setUuids] = useState<string[]>([]); |
| 106 | + const [copied, setCopied] = useState(false); |
| 107 | + const [isDragging, setIsDragging] = useState(false); |
| 108 | + const [activeTab, setActiveTab] = useState<"binary" | "image">("binary"); |
| 109 | + const [error, setError] = useState<string | null>(null); |
| 110 | + const [isEditing, setIsEditing] = useState(false); |
| 111 | + const [editText, setEditText] = useState(""); |
| 112 | + |
| 113 | + const handleFile = useCallback((file: File, mode: "binary" | "image") => { |
| 114 | + setError(null); |
| 115 | + if (mode === "binary") { |
| 116 | + const reader = new FileReader(); |
| 117 | + reader.onload = (e) => { |
| 118 | + const parsed = parseUUIDs(e.target!.result as ArrayBuffer); |
| 119 | + setUuids(parsed); |
| 120 | + if (!parsed.length) setError("No UUIDs found. File may be empty or invalid."); |
| 121 | + }; |
| 122 | + reader.readAsArrayBuffer(file); |
| 123 | + } else { |
| 124 | + const img = new Image(); |
| 125 | + img.onload = () => { |
| 126 | + try { |
| 127 | + const parsed = parseImageUUIDs(img); |
| 128 | + setUuids(parsed); |
| 129 | + if (!parsed.length) setError("No UUIDs found."); |
| 130 | + } catch (err) { |
| 131 | + setError(err instanceof Error ? err.message : "Unknown error"); |
| 132 | + } |
| 133 | + }; |
| 134 | + img.src = URL.createObjectURL(file); |
| 135 | + } |
| 136 | + }, []); |
| 137 | + |
| 138 | + const handleDrop = useCallback((e: React.DragEvent) => { |
| 139 | + e.preventDefault(); |
| 140 | + setIsDragging(false); |
| 141 | + const file = e.dataTransfer.files[0]; |
| 142 | + if (file) handleFile(file, activeTab); |
| 143 | + }, [activeTab, handleFile]); |
| 144 | + |
| 145 | + const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 146 | + const file = e.target.files?.[0]; |
| 147 | + if (file) handleFile(file, activeTab); |
| 148 | + }; |
| 149 | + |
| 150 | + const handleEdit = () => { setEditText(uuids.join("\n")); setIsEditing(true); }; |
| 151 | + |
| 152 | + const handleSave = () => { |
| 153 | + const lines = editText.split("\n").map(l => l.trim()).filter(Boolean); |
| 154 | + const bytes = uuidsToBytes(lines); |
| 155 | + const a = document.createElement("a"); |
| 156 | + a.href = URL.createObjectURL(new Blob([bytes.buffer as ArrayBuffer])); |
| 157 | + a.download = "uuids.bin"; |
| 158 | + a.click(); |
| 159 | + URL.revokeObjectURL(a.href); |
| 160 | + setUuids(lines); |
| 161 | + setIsEditing(false); |
| 162 | + setError(null); |
| 163 | + }; |
| 164 | + |
| 165 | + const copyAll = async () => { |
| 166 | + await navigator.clipboard.writeText(uuids.join("\n")); |
| 167 | + setCopied(true); |
| 168 | + setTimeout(() => setCopied(false), 2000); |
| 169 | + }; |
| 170 | + |
| 171 | + const editLines = isEditing |
| 172 | + ? editText.split("\n").map(l => l.trim().toLowerCase()).filter(Boolean) |
| 173 | + : []; |
| 174 | + const invalidCount = editLines.filter(l => !UUID_RE.test(l)).length; |
| 175 | + const uniqueCount = isEditing |
| 176 | + ? new Set(editLines).size |
| 177 | + : new Set(uuids.map(u => u.toLowerCase())).size; |
| 178 | + |
| 179 | + const saveDisabled = (() => { |
| 180 | + if (!isEditing) return true; |
| 181 | + if (invalidCount > 0) return true; |
| 182 | + const editSet = new Set(editLines); |
| 183 | + const origSet = new Set(uuids.map(u => u.toLowerCase())); |
| 184 | + return editSet.size === origSet.size && [...editSet].every(u => origSet.has(u)); |
| 185 | + })(); |
| 186 | + |
| 187 | + return ( |
| 188 | + <div className="min-h-screen p-6 md:p-12"> |
| 189 | + <div className="mx-auto max-w-2xl"> |
| 190 | + <div className="mb-6 flex items-center justify-between"> |
| 191 | + <h1 className="text-2xl font-semibold">Map Hash Extractor</h1> |
| 192 | + <button |
| 193 | + onClick={toggle} |
| 194 | + className="rounded p-2 hover:bg-muted transition-colors" |
| 195 | + aria-label="Toggle theme" |
| 196 | + > |
| 197 | + {resolved === "dark" ? "☀️" : "🌙"} |
| 198 | + </button> |
| 199 | + </div> |
| 200 | + |
| 201 | + <div className="mb-4 flex gap-2"> |
| 202 | + {(["binary", "image"] as const).map(tab => ( |
| 203 | + <button |
| 204 | + key={tab} |
| 205 | + onClick={() => setActiveTab(tab)} |
| 206 | + className={`px-4 py-2 rounded border transition-colors ${ |
| 207 | + activeTab === tab ? "bg-accent text-white border-accent" : "border-border hover:bg-muted" |
| 208 | + }`} |
| 209 | + > |
| 210 | + {tab === "binary" ? "Binary File" : "PNG Image"} |
| 211 | + </button> |
| 212 | + ))} |
| 213 | + </div> |
| 214 | + |
| 215 | + <div |
| 216 | + className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${ |
| 217 | + isDragging ? "border-accent bg-accent/10" : "border-border hover:border-muted-foreground" |
| 218 | + }`} |
| 219 | + onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} |
| 220 | + onDragLeave={() => setIsDragging(false)} |
| 221 | + onDrop={handleDrop} |
| 222 | + > |
| 223 | + <p className="mb-2 text-muted-foreground"> |
| 224 | + {activeTab === "binary" ? "Drop binary file here" : "Drop PNG image here"} |
| 225 | + </p> |
| 226 | + {activeTab === "image" && ( |
| 227 | + <p className="mb-4 text-xs text-muted-foreground">Dimensions must be 128×128 multiples. Lossy formats unsupported.</p> |
| 228 | + )} |
| 229 | + <label className="inline-block cursor-pointer px-4 py-2 rounded bg-muted hover:bg-border transition-colors"> |
| 230 | + <input type="file" className="hidden" onChange={handleInput} accept={activeTab === "image" ? "image/png" : "*"} key={activeTab} /> |
| 231 | + Select {activeTab === "binary" ? "File" : "PNG"} |
| 232 | + </label> |
| 233 | + </div> |
| 234 | + |
| 235 | + {error && <p className="mt-4 text-red-500 text-sm">{error}</p>} |
| 236 | + |
| 237 | + {uuids.length > 0 && ( |
| 238 | + <div className="mt-6 border border-border rounded-lg p-4"> |
| 239 | + <div className="mb-3 flex items-center justify-between"> |
| 240 | + <span className="text-sm text-muted-foreground"> |
| 241 | + {isEditing && invalidCount > 0 |
| 242 | + ? <span className="text-red-500">{invalidCount} invalid UUID{invalidCount !== 1 && "s"}</span> |
| 243 | + : `${uniqueCount} UUID${uniqueCount !== 1 ? "s" : ""}`} |
| 244 | + </span> |
| 245 | + <div className="flex gap-2"> |
| 246 | + <button onClick={copyAll} className="px-3 py-1 text-sm rounded hover:bg-muted transition-colors"> |
| 247 | + {copied ? "✓ Copied" : "Copy All"} |
| 248 | + </button> |
| 249 | + {isEditing ? ( |
| 250 | + <button |
| 251 | + onClick={handleSave} |
| 252 | + disabled={saveDisabled} |
| 253 | + className={`px-3 py-1 text-sm rounded transition-colors ${ |
| 254 | + saveDisabled ? "bg-muted text-muted-foreground cursor-not-allowed" : "bg-accent text-white hover:bg-accent/80" |
| 255 | + }`} |
| 256 | + > |
| 257 | + Save |
| 258 | + </button> |
| 259 | + ) : ( |
| 260 | + <button onClick={handleEdit} className="px-3 py-1 text-sm rounded hover:bg-muted transition-colors">Edit</button> |
| 261 | + )} |
| 262 | + </div> |
| 263 | + </div> |
| 264 | + <div className="max-h-96 overflow-auto rounded bg-muted p-4"> |
| 265 | + {isEditing ? ( |
| 266 | + <textarea |
| 267 | + value={editText} |
| 268 | + onChange={(e) => setEditText(e.target.value)} |
| 269 | + className="w-full h-64 font-mono text-sm bg-transparent border-none outline-none resize-none" |
| 270 | + /> |
| 271 | + ) : ( |
| 272 | + <pre className="font-mono text-sm whitespace-pre-wrap break-all">{uuids.join("\n")}</pre> |
| 273 | + )} |
| 274 | + </div> |
| 275 | + </div> |
| 276 | + )} |
| 277 | + </div> |
| 278 | + </div> |
| 279 | + ); |
| 280 | +} |
0 commit comments