--[[ zip.lua — A zip writer module for LÖVE 2D ========================================== Uses love.math.compress (zlib/DEFLATE) and a pure-Lua CRC-32 table. No external dependencies required. Requires: LÖVE 11.0+ Usage: local Zip = require("zip") local z = Zip.new() z:add("level1.lua", love.filesystem.read("level1.lua")) z:add("tiles/grass.png", love.filesystem.read("tiles/grass.png")) local data = z:build() love.filesystem.write("levels.zip", data) API: Zip.new() → creates a new Zip writer zip:add(path, data) → adds a file entry (path uses forward slashes) zip:build() → returns the zip file as a string ]] local Zip = {} Zip.__index = Zip -- ── helpers ────────────────────────────────────────────────────────────────── --- Pack bytes into a little-endian binary string. local function pack_u16(n) return string.char(n % 256, math.floor(n / 256) % 256) end local function pack_u32(n) return string.char( n % 256, math.floor(n / 256) % 256, math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256 ) end -- LuaJIT (used by LOVE) provides the 'bit' library for bitwise operations. local bxor = bit.bxor local band = bit.band local rshift = bit.rshift --- CRC-32 lookup table (standard IEEE 802.3 polynomial 0xEDB88320, reflected). local _crc_table = (function() local t = {} for i = 0, 255 do local c = i for _ = 1, 8 do if band(c, 1) == 1 then c = bxor(rshift(c, 1), 0xEDB88320) else c = rshift(c, 1) end end t[i] = c end return t end)() --- Compute CRC-32 of a string (LuaJIT / LOVE compatible). local function crc32(data) local crc = 0xFFFFFFFF for i = 1, #data do local idx = band(bxor(crc, data:byte(i)), 0xFF) crc = bxor(rshift(crc, 8), _crc_table[idx]) end return band(bxor(crc, 0xFFFFFFFF), 0xFFFFFFFF) end --- Deflate-compress data using love.math.compress. --- Returns compressed_string, or nil if compression made it larger. local function deflate(data) -- "zlib" wraps DEFLATE with a 2-byte header and 4-byte Adler-32 checksum. -- For ZIP we need raw DEFLATE, which we get by stripping the zlib envelope. -- love.math.compress( rawstring, format, level ) -- love.data.compress( container, format, rawstring, level ) local zlib_data = love.data.compress("string", "zlib", data, 6) -- level 6 = default -- Strip the 2-byte zlib header and 4-byte Adler-32 trailer local raw_deflate = zlib_data:sub(3, -5) if #raw_deflate >= #data then return nil -- not worth compressing end return raw_deflate end --- MS-DOS date/time encoding for the current time. local function dos_datetime() local t = os.date("*t") local date = ((t.year - 1980) * 512) + (t.month * 32) + t.day local time = (t.hour * 2048) + (t.min * 32) + math.floor(t.sec / 2) return pack_u16(time), pack_u16(date) end -- ── Zip writer ──────────────────────────────────────────────────────────────── --- Create a new Zip writer instance. function Zip.new() return setmetatable({ _entries = {}, _offset = 0 }, Zip) end --- Add a file to the zip. -- @param path string Archive path, e.g. "maps/level1.lua". Use '/' separators. -- @param data string Raw file contents. function Zip:add(path, data) assert(type(path) == "string", "path must be a string") assert(type(data) == "string", "data must be a string") assert(not path:match("^/"), "path must not start with '/'") local crc = crc32(data) local original = #data local compressed, method local deflated = deflate(data) if deflated then compressed = deflated method = 8 -- DEFLATE else compressed = data method = 0 -- Stored end table.insert(self._entries, { path = path, data = data, -- uncompressed (kept for the central dir) compressed = compressed, method = method, crc = crc, size_orig = original, size_comp = #compressed, offset = self._offset, }) -- Local file header = 30 bytes + filename length + compressed data length self._offset = self._offset + 30 + #path + #compressed end --- Build and return the full zip file as a binary string. function Zip:build() local parts = {} -- ── Local file headers + data ──────────────────────────────────────────── for _, e in ipairs(self._entries) do local dos_time, dos_date = dos_datetime() -- Local file header signature parts[#parts+1] = "\x50\x4B\x03\x04" -- PK\3\4 parts[#parts+1] = "\x14\x00" -- version needed: 2.0 parts[#parts+1] = "\x00\x00" -- general purpose bit flag parts[#parts+1] = pack_u16(e.method) parts[#parts+1] = dos_time parts[#parts+1] = dos_date parts[#parts+1] = pack_u32(e.crc) parts[#parts+1] = pack_u32(e.size_comp) parts[#parts+1] = pack_u32(e.size_orig) parts[#parts+1] = pack_u16(#e.path) -- filename length parts[#parts+1] = "\x00\x00" -- extra field length parts[#parts+1] = e.path parts[#parts+1] = e.compressed end local central_dir_offset = 0 for _, p in ipairs(parts) do central_dir_offset = central_dir_offset + #p end -- ── Central directory ──────────────────────────────────────────────────── local central_parts = {} for _, e in ipairs(self._entries) do local dos_time, dos_date = dos_datetime() central_parts[#central_parts+1] = "\x50\x4B\x01\x02" -- PK\1\2 central_parts[#central_parts+1] = "\x14\x00" -- version made by: 2.0 central_parts[#central_parts+1] = "\x14\x00" -- version needed: 2.0 central_parts[#central_parts+1] = "\x00\x00" -- general purpose bit flag central_parts[#central_parts+1] = pack_u16(e.method) central_parts[#central_parts+1] = dos_time central_parts[#central_parts+1] = dos_date central_parts[#central_parts+1] = pack_u32(e.crc) central_parts[#central_parts+1] = pack_u32(e.size_comp) central_parts[#central_parts+1] = pack_u32(e.size_orig) central_parts[#central_parts+1] = pack_u16(#e.path) -- filename length central_parts[#central_parts+1] = "\x00\x00" -- extra field length central_parts[#central_parts+1] = "\x00\x00" -- file comment length central_parts[#central_parts+1] = "\x00\x00" -- disk number start central_parts[#central_parts+1] = "\x00\x00" -- internal attributes central_parts[#central_parts+1] = "\x00\x00\x00\x00" -- external attributes central_parts[#central_parts+1] = pack_u32(e.offset) -- offset of local header central_parts[#central_parts+1] = e.path end local central_dir_size = 0 for _, p in ipairs(central_parts) do central_dir_size = central_dir_size + #p end -- ── End of central directory record ───────────────────────────────────── local num_entries = #self._entries local eocd = table.concat({ "\x50\x4B\x05\x06", -- PK\5\6 (EOCD signature) "\x00\x00", -- disk number "\x00\x00", -- disk with central dir start pack_u16(num_entries), -- entries on this disk pack_u16(num_entries), -- total entries pack_u32(central_dir_size), -- central dir size pack_u32(central_dir_offset), -- central dir offset "\x00\x00", -- comment length }) return table.concat(parts) .. table.concat(central_parts) .. eocd end return Zip