From 50ff7e8fcea11216625bf71d1d8a4b748cda2ce0 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:52:19 +0300 Subject: [PATCH 1/7] Add FFI ZLib library --- runtime/lua/ffi-zlib.lua | 340 ++++++++++++++++++++++++++ spec/System/TestCommon_spec.lua | 31 +++ spec/System/TestTradeHelpers_spec.lua | 14 ++ src/Classes/CompareBuySimilar.lua | 2 +- src/Classes/TradeHelpers.lua | 46 ++++ src/Classes/TradeQuery.lua | 5 +- src/_SimpleGraphic.def.lua | 36 ++- 7 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 runtime/lua/ffi-zlib.lua diff --git a/runtime/lua/ffi-zlib.lua b/runtime/lua/ffi-zlib.lua new file mode 100644 index 0000000000..c099d98bfe --- /dev/null +++ b/runtime/lua/ffi-zlib.lua @@ -0,0 +1,340 @@ +local ffi = require "ffi" +local ffi_new = ffi.new +local ffi_str = ffi.string +local ffi_sizeof = ffi.sizeof +local ffi_copy = ffi.copy +local tonumber = tonumber + +local _M = { + _VERSION = '0.6.0', +} + +local mt = { __index = _M } + + +ffi.cdef([[ +enum { + Z_NO_FLUSH = 0, + Z_PARTIAL_FLUSH = 1, + Z_SYNC_FLUSH = 2, + Z_FULL_FLUSH = 3, + Z_FINISH = 4, + Z_BLOCK = 5, + Z_TREES = 6, + /* Allowed flush values; see deflate() and inflate() below for details */ + Z_OK = 0, + Z_STREAM_END = 1, + Z_NEED_DICT = 2, + Z_ERRNO = -1, + Z_STREAM_ERROR = -2, + Z_DATA_ERROR = -3, + Z_MEM_ERROR = -4, + Z_BUF_ERROR = -5, + Z_VERSION_ERROR = -6, + /* Return codes for the compression/decompression functions. Negative values + * are errors, positive values are used for special but normal events. + */ + Z_NO_COMPRESSION = 0, + Z_BEST_SPEED = 1, + Z_BEST_COMPRESSION = 9, + Z_DEFAULT_COMPRESSION = -1, + /* compression levels */ + Z_FILTERED = 1, + Z_HUFFMAN_ONLY = 2, + Z_RLE = 3, + Z_FIXED = 4, + Z_DEFAULT_STRATEGY = 0, + /* compression strategy; see deflateInit2() below for details */ + Z_BINARY = 0, + Z_TEXT = 1, + Z_ASCII = Z_TEXT, /* for compatibility with 1.2.2 and earlier */ + Z_UNKNOWN = 2, + /* Possible values of the data_type field (though see inflate()) */ + Z_DEFLATED = 8, + /* The deflate compression method (the only one supported in this version) */ + Z_NULL = 0, /* for initializing zalloc, zfree, opaque */ +}; + + +typedef void* (* z_alloc_func)( void* opaque, unsigned items, unsigned size ); +typedef void (* z_free_func) ( void* opaque, void* address ); + +typedef struct z_stream_s { + char* next_in; + unsigned avail_in; + unsigned long total_in; + char* next_out; + unsigned avail_out; + unsigned long total_out; + char* msg; + void* state; + z_alloc_func zalloc; + z_free_func zfree; + void* opaque; + int data_type; + unsigned long adler; + unsigned long reserved; +} z_stream; + + +const char* zlibVersion(); +const char* zError(int); + +int inflate(z_stream*, int flush); +int inflateEnd(z_stream*); +int inflateInit2_(z_stream*, int windowBits, const char* version, int stream_size); + +int deflate(z_stream*, int flush); +int deflateEnd(z_stream* ); +int deflateInit2_(z_stream*, int level, int method, int windowBits, int memLevel,int strategy, const char *version, int stream_size); + +unsigned long adler32(unsigned long adler, const char *buf, unsigned len); +unsigned long crc32(unsigned long crc, const char *buf, unsigned len); +unsigned long adler32_combine(unsigned long, unsigned long, long); +unsigned long crc32_combine(unsigned long, unsigned long, long); + +]]) + +local zlib +if ffi.os == "Windows" then + zlib = ffi.load("../runtime/zlib1") +elseif ffi.os == "OSX" then + zlib = ffi.load("z") +elseif ffi.os == "Linux" then + zlib = ffi.load("libz.so.1") +else + error("lua-ffi-zlib doesn't support platform: " .. ffi.os) +end + +_M.zlib = zlib + +-- Default to 16k output buffer +local DEFAULT_CHUNK = 16384 + +local Z_OK = zlib.Z_OK +local Z_NO_FLUSH = zlib.Z_NO_FLUSH +local Z_STREAM_END = zlib.Z_STREAM_END +local Z_FINISH = zlib.Z_FINISH +local Z_NEED_DICT = zlib.Z_NEED_DICT +local Z_BUF_ERROR = zlib.Z_BUF_ERROR +local Z_STREAM_ERROR = zlib.Z_STREAM_ERROR + +local function zlib_err(err) + return ffi_str(zlib.zError(err)) +end +_M.zlib_err = zlib_err + +local function createStream(bufsize) + -- Setup Stream + local stream = ffi_new("z_stream") + + -- Create input buffer var + local inbuf = ffi_new('char[?]', bufsize+1) + stream.next_in, stream.avail_in = inbuf, 0 + + -- create the output buffer + local outbuf = ffi_new('char[?]', bufsize) + stream.next_out, stream.avail_out = outbuf, 0 + + return stream, inbuf, outbuf +end +_M.createStream = createStream + +local function initInflate(stream, windowBits) + -- Setup inflate process + local windowBits = windowBits or (15 + 32) -- +32 sets automatic header detection + local version = ffi_str(zlib.zlibVersion()) + + return zlib.inflateInit2_(stream, windowBits, version, ffi_sizeof(stream)) +end +_M.initInflate = initInflate + +local function initDeflate(stream, options) + -- Setup deflate process + local method = zlib.Z_DEFLATED + local level = options.level or zlib.Z_DEFAULT_COMPRESSION + local memLevel = options.memLevel or 8 + local strategy = options.strategy or zlib.Z_DEFAULT_STRATEGY + local windowBits = options.windowBits or (15 + 16) -- +16 sets gzip wrapper not zlib + local version = ffi_str(zlib.zlibVersion()) + + return zlib.deflateInit2_(stream, level, method, windowBits, memLevel, strategy, version, ffi_sizeof(stream)) +end +_M.initDeflate = initDeflate + +local function flushOutput(stream, bufsize, output, outbuf) + -- Calculate available output bytes + local out_sz = bufsize - stream.avail_out + if out_sz == 0 then + return + end + -- Read bytes from output buffer and pass to output function + local ok, err = output(ffi_str(outbuf, out_sz)) + if not ok then + return err + end +end + +local function inflate(input, output, bufsize, stream, inbuf, outbuf) + local zlib_flate = zlib.inflate + local zlib_flateEnd = zlib.inflateEnd + -- Inflate a stream + local err = 0 + repeat + -- Read some input + local data = input(bufsize) + if data ~= nil then + ffi_copy(inbuf, data) + stream.next_in, stream.avail_in = inbuf, #data + else + -- no more input data + stream.avail_in = 0 + end + + if stream.avail_in == 0 then + -- When decompressing we *must* have input bytes + zlib_flateEnd(stream) + return false, "INFLATE: Data error, no input bytes" + end + + -- While the output buffer is being filled completely just keep going + repeat + stream.next_out = outbuf + stream.avail_out = bufsize + -- Process the stream, always Z_NO_FLUSH in inflate mode + err = zlib_flate(stream, Z_NO_FLUSH) + + -- Buffer errors are OK here + if err == Z_BUF_ERROR then + err = Z_OK + end + if err < Z_OK or err == Z_NEED_DICT then + -- Error, clean up and return + zlib_flateEnd(stream) + return false, "INFLATE: "..zlib_err(err), stream + end + -- Write the data out + local err = flushOutput(stream, bufsize, output, outbuf) + if err then + zlib_flateEnd(stream) + return false, "INFLATE: "..err + end + until stream.avail_out ~= 0 + + until err == Z_STREAM_END + + -- Stream finished, clean up and return + zlib_flateEnd(stream) + return true, zlib_err(err) +end +_M.inflate = inflate + +local function deflate(input, output, bufsize, stream, inbuf, outbuf) + local zlib_flate = zlib.deflate + local zlib_flateEnd = zlib.deflateEnd + + -- Deflate a stream + local err = 0 + local mode = Z_NO_FLUSH + repeat + -- Read some input + local data = input(bufsize) + if data ~= nil then + ffi_copy(inbuf, data) + stream.next_in, stream.avail_in = inbuf, #data + else + -- EOF, try and finish up + mode = Z_FINISH + stream.avail_in = 0 + end + + -- While the output buffer is being filled completely just keep going + repeat + stream.next_out = outbuf + stream.avail_out = bufsize + + -- Process the stream + err = zlib_flate(stream, mode) + + -- Only possible *bad* return value here + if err == Z_STREAM_ERROR then + -- Error, clean up and return + zlib_flateEnd(stream) + return false, "DEFLATE: "..zlib_err(err), stream + end + -- Write the data out + local err = flushOutput(stream, bufsize, output, outbuf) + if err then + zlib_flateEnd(stream) + return false, "DEFLATE: "..err + end + until stream.avail_out ~= 0 + + -- In deflate mode all input must be used by this point + if stream.avail_in ~= 0 then + zlib_flateEnd(stream) + return false, "DEFLATE: Input not used" + end + + until err == Z_STREAM_END + + -- Stream finished, clean up and return + zlib_flateEnd(stream) + return true, zlib_err(err) +end +_M.deflate = deflate + +local function adler(str, chksum) + local chksum = chksum or 0 + local str = str or "" + return zlib.adler32(chksum, str, #str) +end +_M.adler = adler + +local function crc(str, chksum) + local chksum = chksum or 0 + local str = str or "" + return zlib.crc32(chksum, str, #str) +end +_M.crc = crc + +function _M.inflateGzip(input, output, bufsize, windowBits) + local bufsize = bufsize or DEFAULT_CHUNK + + -- Takes 2 functions that provide input data from a gzip stream and receives output data + -- Returns uncompressed string + local stream, inbuf, outbuf = createStream(bufsize) + + local init = initInflate(stream, windowBits) + if init == Z_OK then + return inflate(input, output, bufsize, stream, inbuf, outbuf) + else + -- Init error + zlib.inflateEnd(stream) + return false, "INIT: "..zlib_err(init) + end +end + +function _M.deflateGzip(input, output, bufsize, options) + local bufsize = bufsize or DEFAULT_CHUNK + options = options or {} + + -- Takes 2 functions that provide plain input data and receives output data + -- Returns gzip compressed string + local stream, inbuf, outbuf = createStream(bufsize) + + local init = initDeflate(stream, options) + if init == Z_OK then + return deflate(input, output, bufsize, stream, inbuf, outbuf) + else + -- Init error + zlib.deflateEnd(stream) + return false, "INIT: "..zlib_err(init) + end +end + +function _M.version() + return ffi_str(zlib.zlibVersion()) +end + +return _M diff --git a/spec/System/TestCommon_spec.lua b/spec/System/TestCommon_spec.lua index 8e5bf3b838..0c7facccbb 100644 --- a/spec/System/TestCommon_spec.lua +++ b/spec/System/TestCommon_spec.lua @@ -94,4 +94,35 @@ describe("Common", function() -- common.classes.StupidClass = nil -- end) end) + describe("Deflate and Inflate", function() + it("round-trips a simple string", function() + local text = "Hello my name is ????!" + local compressed = Deflate(text) + assert.is_not_nil(compressed) + assert.are.equal(text, Inflate(compressed)) + end) + it("produces a zlib header", function() + local compressed = Deflate("some data to compress") + assert.are.equal(0x78, compressed:byte(1)) + end) + it("round-trips an empty string", function() + local compressed = Deflate("") + assert.is_not_nil(compressed) + assert.are.equal("", Inflate(compressed)) + end) + it("round-trips data larger than the 16k buffer", function() + local text = string.rep("The quick brown fox jumps over the lazy dog. ", 5000) + local compressed = Deflate(text) + assert.is_true(#compressed < #text) + assert.are.equal(text, Inflate(compressed)) + end) + it("round-trips binary data", function() + local bytes = {} + for i = 0, 255 do + bytes[i + 1] = string.char(i) + end + local text = table.concat(bytes) + assert.are.equal(text, Inflate(Deflate(text))) + end) + end) end) \ No newline at end of file diff --git a/spec/System/TestTradeHelpers_spec.lua b/spec/System/TestTradeHelpers_spec.lua index 3045d8a6a4..5c2b2e3e7b 100644 --- a/spec/System/TestTradeHelpers_spec.lua +++ b/spec/System/TestTradeHelpers_spec.lua @@ -169,4 +169,18 @@ describe("TradeHelpers trade hash matching", function() assert.is_nil(tradeHelpers.findTradeIdOption("+100 to IQ", "explicit")) end) end) + describe("gzip decode", function() + local sampleText = "Test string please ignore" + local gzipped = tradeHelpers.B64GzipEncode(sampleText) + local roundTrip = tradeHelpers.B64GzipDecode(gzipped) + + assert.are.Equal("H4sIAAAAAAAACgtJLS5RKC4pysxLVyjISU0sTlXITM/LL0oFAAgo9BkZAAAA", gzipped) + assert.are.Equal(sampleText, roundTrip) + assert.are_not_equal(sampleText, gzipped) + + local longText = string.rep("12345678", 4096) + local gzippedLong = tradeHelpers.B64GzipEncode(longText) + local longRoundTrip = tradeHelpers.B64GzipDecode(gzippedLong) + assert.are.Equal(longText, longRoundTrip) + end) end) diff --git a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua index 6c7fe47c9c..fd869e71e6 100644 --- a/src/Classes/CompareBuySimilar.lua +++ b/src/Classes/CompareBuySimilar.lua @@ -192,7 +192,7 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is return string.format("%%%02X", string.byte(c)) end):gsub(" ", "+") url = url .. "/" .. encodedLeague - url = url .. "?q=" .. urlEncode(queryJson) + url = url .. "?q=" .. tradeHelpers.B64GzipEncode(queryJson) return url end diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index d8a15ce5f7..6e00f8e64b 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -596,4 +596,50 @@ function M.newPlainNumericEdit(anchor, rect, init, prompt, limit, integer, chang end return ctrl end + + +---@param str string String which will be encoded +---@return string result The given string, gzipped and then Base64URL encoded +function M.B64GzipEncode(str) + local zlib = require("ffi-zlib") + local b64 = require("base64") + local results = {} + local idx = 1 + local strLen = #str + zlib.deflateGzip(function(n) + local endIdx = math.min(strLen, idx + n) + if idx >= endIdx then + return nil + end + local chunk = string.sub(str, idx, endIdx) + idx = endIdx + 1 + return chunk + end, function(data) + table.insert(results, data) + end) + return b64.encode(table.concat(results)):gsub("%+", "-"):gsub("/", "_") +end + +---@param str string String which will be decoded +---@return string result The given string, Base64URL decoded and the ungzipped +function M.B64GzipDecode(str) + local zlib = require("ffi-zlib") + local b64 = require("base64") + str = b64.decode(str:gsub("%-", "+"):gsub("_", "/")) + local results = {} + local idx = 1 + local strLen = #str + zlib.inflateGzip(function(n) + local endIdx = math.min(strLen, idx + n) + if idx >= endIdx then + return nil + end + local chunk = string.sub(str, idx, endIdx) + idx = endIdx + 1 + return chunk + end, function(data) + table.insert(results, data) + end) + return table.concat(results) +end return M diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 03fd1a1af5..63353ae3a9 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local tradeHelpers = require("Classes.TradeHelpers") local get_time = os.time local t_insert = table.insert @@ -1125,7 +1126,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end if main.api.authToken == nil then local url = self.tradeQueryRequests:buildUrl(self.hostName .. "trade2/search", self.pbRealm, self.pbLeague) - url = url .. "?q=" .. urlEncode(query) + url = url .. "?q=" .. tradeHelpers.B64GzipEncode(query) controls["uri"..context.row_idx]:SetText(url, true) return end @@ -1384,7 +1385,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local exactQueryStr = dkjson.encode(exactQuery) - local encodedUrl = s_format("https://www.pathofexile.com/trade2/search/%s?q=%s", self.pbLeague, urlEncode(exactQueryStr)) + local encodedUrl = s_format("https://www.pathofexile.com/trade2/search/%s?q=%s", self.pbLeague, tradeHelpers.B64GzipEncode(exactQueryStr)) Copy(encodedUrl) OpenURL(encodedUrl) diff --git a/src/_SimpleGraphic.def.lua b/src/_SimpleGraphic.def.lua index 158ed456dc..a4634f1fdd 100644 --- a/src/_SimpleGraphic.def.lua +++ b/src/_SimpleGraphic.def.lua @@ -363,14 +363,46 @@ function Paste() end ---@return string? compressedData ---@return string? errMsg function Deflate(data) - return "" + local zlib = require("ffi-zlib") + local results = {} + local idx = 1 + local strLen = #data + zlib.deflateGzip(function(n) + local endIdx = math.min(strLen, idx + n) + if idx >= endIdx then + return nil + end + local chunk = string.sub(data, idx, endIdx) + idx = endIdx + 1 + return chunk + end, function(outputData) + table.insert(results, outputData) + -- 16k buffer, windowBits 15 for ZLib header + DEFLATE. memLevel 9 is equal to what SG uses + end, 2 ^ 14, { windowBits = 15, memLevel = 9 }) + return table.concat(results) end ---@param data string ---@return string? data ---@return string? errMsg function Inflate(data) - return "" + local zlib = require("ffi-zlib") + local results = {} + local idx = 1 + local strLen = #data + zlib.inflateGzip(function(n) + local endIdx = math.min(strLen, idx + n) + if idx >= endIdx then + return nil + end + local chunk = string.sub(data, idx, endIdx) + idx = endIdx + 1 + return chunk + end, function(outputData) + table.insert(results, outputData) + -- 16k buffer, windowBits 15 for ZLib header + DEFLATE + end, 2 ^ 14, 15) + return table.concat(results) end ---@return integer timeMillis From 4b5ab7a915905dd3844aa94ffabe37712b3e27db Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:35:58 +0300 Subject: [PATCH 2/7] Fix trader for Forbidden Rites --- src/Classes/CompareBuySimilar.lua | 31 +++++++------- src/Classes/TradeQuery.lua | 18 +++++--- src/Classes/TradeQueryGenerator.lua | 65 +++++++++++++---------------- src/Classes/TradeQueryRequests.lua | 59 +++++++------------------- src/Classes/TreeTab.lua | 28 +++++-------- 5 files changed, 83 insertions(+), 118 deletions(-) diff --git a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua index fd869e71e6..0a42d44bb3 100644 --- a/src/Classes/CompareBuySimilar.lua +++ b/src/Classes/CompareBuySimilar.lua @@ -58,16 +58,13 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- Build query local queryTable = { - query = { - status = { option = listedApiValue }, - stats = { - { - type = "and", - filters = {} - } - }, + status = { option = listedApiValue }, + stats = { + { + type = "and", + filters = {} + } }, - sort = { price = "asc" } } local queryFilters = {} @@ -75,8 +72,8 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- Search by unique name -- Strip "Foulborn" prefix from unique name for trade search local tradeName = (item.title or item.name):gsub("^Foulborn%s+", "") - queryTable.query.name = tradeName - queryTable.query.type = item.baseName + queryTable.name = tradeName + queryTable.type = item.baseName -- If item is Foulborn, add the foulborn_item filter if item.foulborn then queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} } @@ -95,7 +92,7 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- Base type filter if controls.baseTypeCheck and controls.baseTypeCheck.state then - queryTable.query.type = item.baseName + queryTable.type = item.baseName end -- Item level filter @@ -165,21 +162,21 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then if #entry.tradeIds == 1 then -- 1 id entries are added to the stat filters section - t_insert(queryTable.query.stats[1].filters, getFilter(entry.tradeIds[1])) + t_insert(queryTable.stats[1].filters, getFilter(entry.tradeIds[1])) elseif #entry.tradeIds > 1 then -- ambiguous entries are added as a separate count filter local countFilter = { type = "count", value = { min = 1 }, filters = {} } for _, tradeId in ipairs(entry.tradeIds) do t_insert(countFilter.filters, getFilter(tradeId)) end - t_insert(queryTable.query.stats, countFilter) + t_insert(queryTable.stats, countFilter) end end end -- Only include filters if we have any if next(queryFilters) then - queryTable.query.filters = queryFilters + queryTable.filters = queryFilters end -- Build URL @@ -191,8 +188,8 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c) return string.format("%%%02X", string.byte(c)) end):gsub(" ", "+") - url = url .. "/" .. encodedLeague - url = url .. "?q=" .. tradeHelpers.B64GzipEncode(queryJson) + url ..= "/" .. encodedLeague + url ..= "/" .. tradeHelpers.B64GzipEncode(queryJson) return url end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 63353ae3a9..2a8284077c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1117,7 +1117,8 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name" .. row_idx] = new("LabelControl"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() - self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg) + ---@param query table A table of filters + local function requestQueryHandler(context, query, errMsg) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) return @@ -1126,11 +1127,17 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end if main.api.authToken == nil then local url = self.tradeQueryRequests:buildUrl(self.hostName .. "trade2/search", self.pbRealm, self.pbLeague) - url = url .. "?q=" .. tradeHelpers.B64GzipEncode(query) + url = url .. "/" .. tradeHelpers.B64GzipEncode(dkjson.encode(query)) controls["uri"..context.row_idx]:SetText(url, true) return end context.controls["priceButton"..context.row_idx].label = "Searching..." + -- the query that can be included in the url only contains the filters, which means we + -- need to modify the query slightly for the POST endpoint + query = dkjson.encode({ + query = query, + sort = { ["statgroup.0"] = "desc" }, + }) self.lastQueries[row_idx] = query self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, function(items, errMsg) @@ -1186,7 +1193,8 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end } ) - end) + end + self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, requestQueryHandler) end) controls["bestButton"..row_idx].shown = function() return not self.resultTbl[row_idx] end controls["bestButton"..row_idx].enabled = function() return self.pbLeague end @@ -1383,9 +1391,9 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite exactQuery.query.filters.trade_filters.filters = exactQuery.query.filters.trade_filters.filters or { } exactQuery.query.filters.trade_filters.filters.account = { input = itemResult.trader } - local exactQueryStr = dkjson.encode(exactQuery) + local exactQueryStr = dkjson.encode(exactQuery.query) - local encodedUrl = s_format("https://www.pathofexile.com/trade2/search/%s?q=%s", self.pbLeague, tradeHelpers.B64GzipEncode(exactQueryStr)) + local encodedUrl = s_format("https://www.pathofexile.com/trade2/search/%s/%s", self.pbLeague, tradeHelpers.B64GzipEncode(exactQueryStr)) Copy(encodedUrl) OpenURL(encodedUrl) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index a8156b37d5..111dcb007c 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -969,38 +969,34 @@ function TradeQueryGeneratorClass:FinishQuery() local requiredMods = self.calcContext.requiredMods or {} local blockedMods = self.calcContext.blockedMods or {} local queryTable = { - query = { - filters = self.calcContext.special.queryFilters or { - type_filters = { - filters = { - category = { option = self.calcContext.itemCategoryQueryStr }, - rarity = { option = "nonunique" } - } + filters = self.calcContext.special.queryFilters or { + type_filters = { + filters = { + category = { option = self.calcContext.itemCategoryQueryStr }, + rarity = { option = "nonunique" } } + } + }, + status = { option = selectedTradeType }, + stats = { + { + type = "weight", + value = { min = minWeight }, + filters = {}, }, - status = { option = selectedTradeType }, - stats = { - { - type = "weight", - value = { min = minWeight }, - filters = {}, - }, - { - type = "and", - filters = {}, - }, - { - type = "not", - filters = {}, - } + { + type = "and", + filters = {}, + }, + { + type = "not", + filters = {}, } }, - sort = { ["statgroup.0"] = "desc" }, - engine = "new" } - local weightGroup = queryTable.query.stats[1] - local andGroup = queryTable.query.stats[2] - local notGroup = queryTable.query.stats[3] + local weightGroup = queryTable.stats[1] + local andGroup = queryTable.stats[2] + local notGroup = queryTable.stats[3] -- the trade site has a maximum complexity of 200 for each query. our baseline is 54 for the weighted sum group, 4 for the rarity filter plus category, and 4 for the and group local complexityBudget = 200 - 54 - 4 - 4 @@ -1056,7 +1052,7 @@ function TradeQueryGeneratorClass:FinishQuery() for k, v in pairs(self.calcContext.special.queryExtra or {}) do complexityBudget = complexityBudget - 2 - queryTable.query[k] = v + queryTable[k] = v end -- and filters specified by the user @@ -1071,7 +1067,7 @@ function TradeQueryGeneratorClass:FinishQuery() local options = self.calcContext.options if not options.includeMirrored then complexityBudget = complexityBudget - 3 - queryTable.query.filters.misc_filters = { + queryTable.filters.misc_filters = { disabled = false, filters = { mirrored = false, @@ -1081,7 +1077,7 @@ function TradeQueryGeneratorClass:FinishQuery() if options.maxPrice and options.maxPrice > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.trade_filters = { + queryTable.filters.trade_filters = { filters = { price = { option = options.maxPriceType, @@ -1093,11 +1089,11 @@ function TradeQueryGeneratorClass:FinishQuery() if options.account then complexityBudget = complexityBudget - 3 - queryTable.query.filters.trade_filters.filters.account = { input = options.account } + queryTable.filters.trade_filters.filters.account = { input = options.account } end if options.maxLevel and options.maxLevel > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.req_filters = { + queryTable.filters.req_filters = { disabled = false, filters = { lvl = { @@ -1109,7 +1105,7 @@ function TradeQueryGeneratorClass:FinishQuery() if options.sockets and options.sockets > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.equipment_filters = { + queryTable.filters.equipment_filters = { disabled = false, filters = { rune_sockets = { @@ -1134,8 +1130,7 @@ function TradeQueryGeneratorClass:FinishQuery() errMsg = "Could not generate search, found no mods to search for" end - local queryJson = dkjson.encode(queryTable) - self.requesterCallback(self.requesterContext, queryJson, errMsg) + self.requesterCallback(self.requesterContext, queryTable, errMsg) -- Close blocker popup main:ClosePopup() diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 56e3720b71..7f0de1df78 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -6,6 +6,7 @@ local dkjson = require "dkjson" local utils = LoadModule("Modules/Utils") +local tradeHelpers = require("Classes.TradeHelpers") ---@class TradeQueryRequests ---@class TradeQueryRequests @@ -178,7 +179,10 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu else if response.total < self.maxFetchPerSearch then -- Less than maximum items retrieved lower weight to try and get more. local queryJson = dkjson.decode(query) - queryJson.query.stats[1].value.min = queryJson.query.stats[1].value.min / 2 + if not queryJson.query.stats[1].value then + queryJson.query.stats[1].value = { min = 0 } + end + queryJson.query.stats[1].value.min = (queryJson.query.stats[1].value.min or 0) / 2 query = dkjson.encode(queryJson) self:PerformSearch(realm, league, query, performSearchCallback) else -- Search clipped, fetch highest weight item, update query weight and repeat search @@ -192,6 +196,9 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu previousSearchItems = items local highestWeight = items[1].weight local queryJson = dkjson.decode(query) + if not queryJson.query.stats[1].value then + queryJson.query.stats[1].value = { min = 0 } + end queryJson.query.stats[1].value.min = (tonumber(highestWeight) + queryJson.query.stats[1].value.min) / 2 query = dkjson.encode(queryJson) self:PerformSearch(realm, league, query, performSearchCallback) @@ -469,49 +476,13 @@ function TradeQueryRequestsClass:SearchWithURL(url, callback) end league = paths[#paths-1] queryId = paths[#paths] - self:FetchSearchQuery(realm, league, queryId, function(query, errMsg) - if errMsg then - return callback(nil, errMsg, nil) - end - - -- update sorting on provided url to sort by weights. - local json_data = dkjson.decode(query) - if not json_data or json_data.error then - errMsg = json_data and json_data.error or "Failed to parse search query JSON" - end - if json_data.query.stats and json_data.query.stats[1] and json_data.query.stats[1].type == "weight" then - json_data.sort = {} - json_data.sort["statgroup.0"] = "desc" - else - json_data.sort = { price = "asc"} - end - query = dkjson.encode(json_data) - - self:SearchWithQuery(realm, league, query, function(items, searchErrMsg) - callback(items, searchErrMsg, query) - end) - end) -end - ----Fetch query data needed to perform the search ----@param queryId string ----@param league string ----@param callback fun(query:string, errMsg:string) -function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callback) - local url = self:buildUrl(self.hostName .. "api/trade2/search", realm, league, queryId) - table.insert(self.requestQueue["search"], { - url = url, - callback = function(response, errMsg) - if errMsg then - return callback(nil, errMsg) - end - local json_data = dkjson.decode(response) - if not json_data or json_data.error then - errMsg = json_data and json_data.error or "Failed to get search query" - end - callback(response, errMsg) - end - }) + local queryIdDecoded = dkjson.decode(tradeHelpers.B64GzipDecode(queryId)) + local newQuery = { + query = queryIdDecoded or {}, + sort = { ["statgroup.0"] = "desc" }, + engine = "new" + } + self:SearchWithQueryWeightAdjusted(realm, league, dkjson.encode(newQuery), callback) end --- Fetches the list of all available leagues using trade2 league API diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 1739cee6b3..b6f9720cc2 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -18,6 +18,7 @@ local m_abs = math.abs local s_format = string.format local s_gsub = string.gsub local s_byte = string.byte +local tradeHelpers = require("Classes.TradeHelpers") local dkjson = require "dkjson" -- Helper function to find toast index by content pattern @@ -2012,22 +2013,17 @@ function TreeTabClass:FindTimelessJewel() end local search = { - query = { - status = { - option = "available" - }, - stats = { - { - filters = seedTrades, - type = "count", - value = { - min = 1 - } + status = { + option = "available" + }, + stats = { + { + filters = seedTrades, + type = "count", + value = { + min = 1 } } - }, - sort = { - price = "asc" } } @@ -2050,9 +2046,7 @@ function TreeTabClass:FindTimelessJewel() -- if the league was not selected via dropdown, then default to the first league in the dropdown or "" if the leagues could not be read self.timelessJewelLeagueSelect = self.timelessJewelLeagueSelect or (self.tradeLeaguesList and #self.tradeLeaguesList > 0 and self.tradeLeaguesList[1]) or "" - Copy("https://www.pathofexile.com/trade/search/"..(self.timelessJewelLeagueSelect).."/?q=" .. (s_gsub(dkjson.encode(search), "[^a-zA-Z0-9]", function(a) - return s_format("%%%02X", s_byte(a)) - end))) + Copy("https://www.pathofexile.com/trade/search/" .. (self.timelessJewelLeagueSelect) .. "/" .. tradeHelpers.B64GzipEncode(dkjson.encode(search))) controls.searchTradeButton.label = "Copy Next Trade URL" end) From 15b543e9770d6e492ca59effb7d86113bc33412e Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:50:48 +0300 Subject: [PATCH 3/7] Fix tests --- spec/System/TestCompareBuySimilar_spec.lua | 12 +++++++----- spec/System/TestTradeHelpers_spec.lua | 4 +++- spec/System/TestTradeQueryGenerator_spec.lua | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/spec/System/TestCompareBuySimilar_spec.lua b/spec/System/TestCompareBuySimilar_spec.lua index 0b313e4f0c..b7e28a66a0 100644 --- a/spec/System/TestCompareBuySimilar_spec.lua +++ b/spec/System/TestCompareBuySimilar_spec.lua @@ -149,12 +149,12 @@ Implicits: 1 controls.mod1Check.state = true controls.mod1Check.changeFunc(true) controls.search.onClick() - local queryJson = copiedUrl:match("%?q=(.*)"):gsub("%%(%x%x)", function(hex) + local queryB64 = copiedUrl:match("Test%%20League/(.*)$"):gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) - local query = require("dkjson").decode(queryJson) + local query = require("dkjson").decode(require("Classes.TradeHelpers").B64GzipDecode(queryB64)) - assert.same({ { type = "and", filters = { { id = "explicit.stat_1526933524" } } } }, query.query.stats) + assert.same({ { type = "and", filters = { { id = "explicit.stat_1526933524" } } } }, query.stats) end) it("rebuilds the URL when league and listed status change", function() @@ -165,13 +165,15 @@ Implicits: 1 controls.leagueDrop:SetSel(2) controls.search.onClick() assert.not_equal(initialUrl, copiedUrl) - assert.is_truthy(copiedUrl:find("/Standard?", 1, true)) + assert.is_truthy(copiedUrl:find("/Standard/", 1, true)) local standardUrl = copiedUrl controls.listedDrop:SetSel(4) controls.search.onClick() assert.not_equal(standardUrl, copiedUrl) - assert.is_truthy(copiedUrl:find("any", 1, true)) + local b64 = copiedUrl:match("Standard/(.-)$") + local json = require("Classes.TradeHelpers").B64GzipDecode(b64) + assert.is_truthy(json:find("any", 1, true)) end) it("persists popup selector choices", function() diff --git a/spec/System/TestTradeHelpers_spec.lua b/spec/System/TestTradeHelpers_spec.lua index 5c2b2e3e7b..ac241341c0 100644 --- a/spec/System/TestTradeHelpers_spec.lua +++ b/spec/System/TestTradeHelpers_spec.lua @@ -174,7 +174,9 @@ describe("TradeHelpers trade hash matching", function() local gzipped = tradeHelpers.B64GzipEncode(sampleText) local roundTrip = tradeHelpers.B64GzipDecode(gzipped) - assert.are.Equal("H4sIAAAAAAAACgtJLS5RKC4pysxLVyjISU0sTlXITM/LL0oFAAgo9BkZAAAA", gzipped) + -- spell-checker: disable + assert.are.Equal("H4sIAAAAAAAACgtJLS5RKC4pysxLVyjISU0sTlXITM_LL0oFAAgo9BkZAAAA", gzipped) + -- spell-checker: enable assert.are.Equal(sampleText, roundTrip) assert.are_not_equal(sampleText, gzipped) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 47e0dc10a3..1d4e788ff6 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -148,8 +148,8 @@ describe("TradeQueryGenerator", function() } queryGen.tradeTypeIndex = 1 local query - queryGen.requesterCallback = function(_, queryJson) - query = require("dkjson").decode(queryJson).query + queryGen.requesterCallback = function(_, queryTable) + query = queryTable end queryGen:FinishQuery() From 2e1d7ceb7f249f69d418733e1922e526cffd9010 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:07:29 +0300 Subject: [PATCH 4/7] Remove gzip exact string test due to docker version differences --- spec/System/TestTradeHelpers_spec.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/spec/System/TestTradeHelpers_spec.lua b/spec/System/TestTradeHelpers_spec.lua index ac241341c0..b8d9bbf12e 100644 --- a/spec/System/TestTradeHelpers_spec.lua +++ b/spec/System/TestTradeHelpers_spec.lua @@ -174,9 +174,6 @@ describe("TradeHelpers trade hash matching", function() local gzipped = tradeHelpers.B64GzipEncode(sampleText) local roundTrip = tradeHelpers.B64GzipDecode(gzipped) - -- spell-checker: disable - assert.are.Equal("H4sIAAAAAAAACgtJLS5RKC4pysxLVyjISU0sTlXITM_LL0oFAAgo9BkZAAAA", gzipped) - -- spell-checker: enable assert.are.Equal(sampleText, roundTrip) assert.are_not_equal(sampleText, gzipped) From 3aacaa2cfb3cdff10d919ad49a76df53e5e74c3f Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:26:00 +0300 Subject: [PATCH 5/7] Add license --- LICENSE.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index 954c2cf772..9b79ee4276 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1358,4 +1358,32 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. + +******************************************************************************* + +lua-ffi-zlib: + +******************************************************************************* + +MIT License + +Copyright (c) 2016 Hamish Forbes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 46835c315548f208ff9ae90adc6399fa5b003c9f Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:16:37 +0300 Subject: [PATCH 6/7] Revert addition of zlib library --- LICENSE.md | 30 +--- runtime/lua/ffi-zlib.lua | 340 ----------------------------------- src/Classes/TradeHelpers.lua | 35 +--- src/_SimpleGraphic.def.lua | 42 +---- 4 files changed, 9 insertions(+), 438 deletions(-) delete mode 100644 runtime/lua/ffi-zlib.lua diff --git a/LICENSE.md b/LICENSE.md index 9b79ee4276..954c2cf772 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1358,32 +1358,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -******************************************************************************* - -lua-ffi-zlib: - -******************************************************************************* - -MIT License - -Copyright (c) 2016 Hamish Forbes - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/runtime/lua/ffi-zlib.lua b/runtime/lua/ffi-zlib.lua deleted file mode 100644 index c099d98bfe..0000000000 --- a/runtime/lua/ffi-zlib.lua +++ /dev/null @@ -1,340 +0,0 @@ -local ffi = require "ffi" -local ffi_new = ffi.new -local ffi_str = ffi.string -local ffi_sizeof = ffi.sizeof -local ffi_copy = ffi.copy -local tonumber = tonumber - -local _M = { - _VERSION = '0.6.0', -} - -local mt = { __index = _M } - - -ffi.cdef([[ -enum { - Z_NO_FLUSH = 0, - Z_PARTIAL_FLUSH = 1, - Z_SYNC_FLUSH = 2, - Z_FULL_FLUSH = 3, - Z_FINISH = 4, - Z_BLOCK = 5, - Z_TREES = 6, - /* Allowed flush values; see deflate() and inflate() below for details */ - Z_OK = 0, - Z_STREAM_END = 1, - Z_NEED_DICT = 2, - Z_ERRNO = -1, - Z_STREAM_ERROR = -2, - Z_DATA_ERROR = -3, - Z_MEM_ERROR = -4, - Z_BUF_ERROR = -5, - Z_VERSION_ERROR = -6, - /* Return codes for the compression/decompression functions. Negative values - * are errors, positive values are used for special but normal events. - */ - Z_NO_COMPRESSION = 0, - Z_BEST_SPEED = 1, - Z_BEST_COMPRESSION = 9, - Z_DEFAULT_COMPRESSION = -1, - /* compression levels */ - Z_FILTERED = 1, - Z_HUFFMAN_ONLY = 2, - Z_RLE = 3, - Z_FIXED = 4, - Z_DEFAULT_STRATEGY = 0, - /* compression strategy; see deflateInit2() below for details */ - Z_BINARY = 0, - Z_TEXT = 1, - Z_ASCII = Z_TEXT, /* for compatibility with 1.2.2 and earlier */ - Z_UNKNOWN = 2, - /* Possible values of the data_type field (though see inflate()) */ - Z_DEFLATED = 8, - /* The deflate compression method (the only one supported in this version) */ - Z_NULL = 0, /* for initializing zalloc, zfree, opaque */ -}; - - -typedef void* (* z_alloc_func)( void* opaque, unsigned items, unsigned size ); -typedef void (* z_free_func) ( void* opaque, void* address ); - -typedef struct z_stream_s { - char* next_in; - unsigned avail_in; - unsigned long total_in; - char* next_out; - unsigned avail_out; - unsigned long total_out; - char* msg; - void* state; - z_alloc_func zalloc; - z_free_func zfree; - void* opaque; - int data_type; - unsigned long adler; - unsigned long reserved; -} z_stream; - - -const char* zlibVersion(); -const char* zError(int); - -int inflate(z_stream*, int flush); -int inflateEnd(z_stream*); -int inflateInit2_(z_stream*, int windowBits, const char* version, int stream_size); - -int deflate(z_stream*, int flush); -int deflateEnd(z_stream* ); -int deflateInit2_(z_stream*, int level, int method, int windowBits, int memLevel,int strategy, const char *version, int stream_size); - -unsigned long adler32(unsigned long adler, const char *buf, unsigned len); -unsigned long crc32(unsigned long crc, const char *buf, unsigned len); -unsigned long adler32_combine(unsigned long, unsigned long, long); -unsigned long crc32_combine(unsigned long, unsigned long, long); - -]]) - -local zlib -if ffi.os == "Windows" then - zlib = ffi.load("../runtime/zlib1") -elseif ffi.os == "OSX" then - zlib = ffi.load("z") -elseif ffi.os == "Linux" then - zlib = ffi.load("libz.so.1") -else - error("lua-ffi-zlib doesn't support platform: " .. ffi.os) -end - -_M.zlib = zlib - --- Default to 16k output buffer -local DEFAULT_CHUNK = 16384 - -local Z_OK = zlib.Z_OK -local Z_NO_FLUSH = zlib.Z_NO_FLUSH -local Z_STREAM_END = zlib.Z_STREAM_END -local Z_FINISH = zlib.Z_FINISH -local Z_NEED_DICT = zlib.Z_NEED_DICT -local Z_BUF_ERROR = zlib.Z_BUF_ERROR -local Z_STREAM_ERROR = zlib.Z_STREAM_ERROR - -local function zlib_err(err) - return ffi_str(zlib.zError(err)) -end -_M.zlib_err = zlib_err - -local function createStream(bufsize) - -- Setup Stream - local stream = ffi_new("z_stream") - - -- Create input buffer var - local inbuf = ffi_new('char[?]', bufsize+1) - stream.next_in, stream.avail_in = inbuf, 0 - - -- create the output buffer - local outbuf = ffi_new('char[?]', bufsize) - stream.next_out, stream.avail_out = outbuf, 0 - - return stream, inbuf, outbuf -end -_M.createStream = createStream - -local function initInflate(stream, windowBits) - -- Setup inflate process - local windowBits = windowBits or (15 + 32) -- +32 sets automatic header detection - local version = ffi_str(zlib.zlibVersion()) - - return zlib.inflateInit2_(stream, windowBits, version, ffi_sizeof(stream)) -end -_M.initInflate = initInflate - -local function initDeflate(stream, options) - -- Setup deflate process - local method = zlib.Z_DEFLATED - local level = options.level or zlib.Z_DEFAULT_COMPRESSION - local memLevel = options.memLevel or 8 - local strategy = options.strategy or zlib.Z_DEFAULT_STRATEGY - local windowBits = options.windowBits or (15 + 16) -- +16 sets gzip wrapper not zlib - local version = ffi_str(zlib.zlibVersion()) - - return zlib.deflateInit2_(stream, level, method, windowBits, memLevel, strategy, version, ffi_sizeof(stream)) -end -_M.initDeflate = initDeflate - -local function flushOutput(stream, bufsize, output, outbuf) - -- Calculate available output bytes - local out_sz = bufsize - stream.avail_out - if out_sz == 0 then - return - end - -- Read bytes from output buffer and pass to output function - local ok, err = output(ffi_str(outbuf, out_sz)) - if not ok then - return err - end -end - -local function inflate(input, output, bufsize, stream, inbuf, outbuf) - local zlib_flate = zlib.inflate - local zlib_flateEnd = zlib.inflateEnd - -- Inflate a stream - local err = 0 - repeat - -- Read some input - local data = input(bufsize) - if data ~= nil then - ffi_copy(inbuf, data) - stream.next_in, stream.avail_in = inbuf, #data - else - -- no more input data - stream.avail_in = 0 - end - - if stream.avail_in == 0 then - -- When decompressing we *must* have input bytes - zlib_flateEnd(stream) - return false, "INFLATE: Data error, no input bytes" - end - - -- While the output buffer is being filled completely just keep going - repeat - stream.next_out = outbuf - stream.avail_out = bufsize - -- Process the stream, always Z_NO_FLUSH in inflate mode - err = zlib_flate(stream, Z_NO_FLUSH) - - -- Buffer errors are OK here - if err == Z_BUF_ERROR then - err = Z_OK - end - if err < Z_OK or err == Z_NEED_DICT then - -- Error, clean up and return - zlib_flateEnd(stream) - return false, "INFLATE: "..zlib_err(err), stream - end - -- Write the data out - local err = flushOutput(stream, bufsize, output, outbuf) - if err then - zlib_flateEnd(stream) - return false, "INFLATE: "..err - end - until stream.avail_out ~= 0 - - until err == Z_STREAM_END - - -- Stream finished, clean up and return - zlib_flateEnd(stream) - return true, zlib_err(err) -end -_M.inflate = inflate - -local function deflate(input, output, bufsize, stream, inbuf, outbuf) - local zlib_flate = zlib.deflate - local zlib_flateEnd = zlib.deflateEnd - - -- Deflate a stream - local err = 0 - local mode = Z_NO_FLUSH - repeat - -- Read some input - local data = input(bufsize) - if data ~= nil then - ffi_copy(inbuf, data) - stream.next_in, stream.avail_in = inbuf, #data - else - -- EOF, try and finish up - mode = Z_FINISH - stream.avail_in = 0 - end - - -- While the output buffer is being filled completely just keep going - repeat - stream.next_out = outbuf - stream.avail_out = bufsize - - -- Process the stream - err = zlib_flate(stream, mode) - - -- Only possible *bad* return value here - if err == Z_STREAM_ERROR then - -- Error, clean up and return - zlib_flateEnd(stream) - return false, "DEFLATE: "..zlib_err(err), stream - end - -- Write the data out - local err = flushOutput(stream, bufsize, output, outbuf) - if err then - zlib_flateEnd(stream) - return false, "DEFLATE: "..err - end - until stream.avail_out ~= 0 - - -- In deflate mode all input must be used by this point - if stream.avail_in ~= 0 then - zlib_flateEnd(stream) - return false, "DEFLATE: Input not used" - end - - until err == Z_STREAM_END - - -- Stream finished, clean up and return - zlib_flateEnd(stream) - return true, zlib_err(err) -end -_M.deflate = deflate - -local function adler(str, chksum) - local chksum = chksum or 0 - local str = str or "" - return zlib.adler32(chksum, str, #str) -end -_M.adler = adler - -local function crc(str, chksum) - local chksum = chksum or 0 - local str = str or "" - return zlib.crc32(chksum, str, #str) -end -_M.crc = crc - -function _M.inflateGzip(input, output, bufsize, windowBits) - local bufsize = bufsize or DEFAULT_CHUNK - - -- Takes 2 functions that provide input data from a gzip stream and receives output data - -- Returns uncompressed string - local stream, inbuf, outbuf = createStream(bufsize) - - local init = initInflate(stream, windowBits) - if init == Z_OK then - return inflate(input, output, bufsize, stream, inbuf, outbuf) - else - -- Init error - zlib.inflateEnd(stream) - return false, "INIT: "..zlib_err(init) - end -end - -function _M.deflateGzip(input, output, bufsize, options) - local bufsize = bufsize or DEFAULT_CHUNK - options = options or {} - - -- Takes 2 functions that provide plain input data and receives output data - -- Returns gzip compressed string - local stream, inbuf, outbuf = createStream(bufsize) - - local init = initDeflate(stream, options) - if init == Z_OK then - return deflate(input, output, bufsize, stream, inbuf, outbuf) - else - -- Init error - zlib.deflateEnd(stream) - return false, "INIT: "..zlib_err(init) - end -end - -function _M.version() - return ffi_str(zlib.zlibVersion()) -end - -return _M diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 6e00f8e64b..990c691747 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -601,45 +601,14 @@ end ---@param str string String which will be encoded ---@return string result The given string, gzipped and then Base64URL encoded function M.B64GzipEncode(str) - local zlib = require("ffi-zlib") local b64 = require("base64") - local results = {} - local idx = 1 - local strLen = #str - zlib.deflateGzip(function(n) - local endIdx = math.min(strLen, idx + n) - if idx >= endIdx then - return nil - end - local chunk = string.sub(str, idx, endIdx) - idx = endIdx + 1 - return chunk - end, function(data) - table.insert(results, data) - end) - return b64.encode(table.concat(results)):gsub("%+", "-"):gsub("/", "_") + return b64.encode(Deflate(str, true)):gsub("%+", "-"):gsub("/", "_") end ---@param str string String which will be decoded ---@return string result The given string, Base64URL decoded and the ungzipped function M.B64GzipDecode(str) - local zlib = require("ffi-zlib") local b64 = require("base64") - str = b64.decode(str:gsub("%-", "+"):gsub("_", "/")) - local results = {} - local idx = 1 - local strLen = #str - zlib.inflateGzip(function(n) - local endIdx = math.min(strLen, idx + n) - if idx >= endIdx then - return nil - end - local chunk = string.sub(str, idx, endIdx) - idx = endIdx + 1 - return chunk - end, function(data) - table.insert(results, data) - end) - return table.concat(results) + return Inflate(b64.decode(str:gsub("%-", "+"):gsub("_", "/"))) end return M diff --git a/src/_SimpleGraphic.def.lua b/src/_SimpleGraphic.def.lua index a4634f1fdd..420c19efa0 100644 --- a/src/_SimpleGraphic.def.lua +++ b/src/_SimpleGraphic.def.lua @@ -360,49 +360,19 @@ function Copy(text) end function Paste() end ---@param data string +---@param isGzip boolean? Whether a Gzip header should be used instead of the default ZLib header. ---@return string? compressedData ---@return string? errMsg -function Deflate(data) - local zlib = require("ffi-zlib") - local results = {} - local idx = 1 - local strLen = #data - zlib.deflateGzip(function(n) - local endIdx = math.min(strLen, idx + n) - if idx >= endIdx then - return nil - end - local chunk = string.sub(data, idx, endIdx) - idx = endIdx + 1 - return chunk - end, function(outputData) - table.insert(results, outputData) - -- 16k buffer, windowBits 15 for ZLib header + DEFLATE. memLevel 9 is equal to what SG uses - end, 2 ^ 14, { windowBits = 15, memLevel = 9 }) - return table.concat(results) +function Deflate(data, isGzip) + -- TODO: add FFI bindings to `runtime/zlib1.dll` similar to what SimpleGraphic does + return "" end ----@param data string +---@param data string DEFLATE data with either ZLib or Gzip headers. The format is detected automatically. Raw DEFLATE data is not supported. ---@return string? data ---@return string? errMsg function Inflate(data) - local zlib = require("ffi-zlib") - local results = {} - local idx = 1 - local strLen = #data - zlib.inflateGzip(function(n) - local endIdx = math.min(strLen, idx + n) - if idx >= endIdx then - return nil - end - local chunk = string.sub(data, idx, endIdx) - idx = endIdx + 1 - return chunk - end, function(outputData) - table.insert(results, outputData) - -- 16k buffer, windowBits 15 for ZLib header + DEFLATE - end, 2 ^ 14, 15) - return table.concat(results) + return "" end ---@return integer timeMillis From a41050616456fc5d7318b410d58e91b60474d591 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:10:04 +0300 Subject: [PATCH 7/7] Improve error handling and disable tests broken by zlib approach change --- spec/System/TestCommon_spec.lua | 63 +++++++++++----------- spec/System/TestCompareBuySimilar_spec.lua | 27 +++++----- spec/System/TestTradeHelpers_spec.lua | 27 +++++----- src/Classes/TradeHelpers.lua | 12 +++-- src/Classes/TradeQueryRequests.lua | 45 ++++++++++++---- 5 files changed, 103 insertions(+), 71 deletions(-) diff --git a/spec/System/TestCommon_spec.lua b/spec/System/TestCommon_spec.lua index 0c7facccbb..2f2e4f64fc 100644 --- a/spec/System/TestCommon_spec.lua +++ b/spec/System/TestCommon_spec.lua @@ -94,35 +94,36 @@ describe("Common", function() -- common.classes.StupidClass = nil -- end) end) - describe("Deflate and Inflate", function() - it("round-trips a simple string", function() - local text = "Hello my name is ????!" - local compressed = Deflate(text) - assert.is_not_nil(compressed) - assert.are.equal(text, Inflate(compressed)) - end) - it("produces a zlib header", function() - local compressed = Deflate("some data to compress") - assert.are.equal(0x78, compressed:byte(1)) - end) - it("round-trips an empty string", function() - local compressed = Deflate("") - assert.is_not_nil(compressed) - assert.are.equal("", Inflate(compressed)) - end) - it("round-trips data larger than the 16k buffer", function() - local text = string.rep("The quick brown fox jumps over the lazy dog. ", 5000) - local compressed = Deflate(text) - assert.is_true(#compressed < #text) - assert.are.equal(text, Inflate(compressed)) - end) - it("round-trips binary data", function() - local bytes = {} - for i = 0, 255 do - bytes[i + 1] = string.char(i) - end - local text = table.concat(bytes) - assert.are.equal(text, Inflate(Deflate(text))) - end) - end) + -- tests for headless wrapper implementation of deflate/inflate. disabled until support is added + -- describe("Deflate and Inflate", function() + -- it("round-trips a simple string", function() + -- local text = "Hello my name is ????!" + -- local compressed = Deflate(text) + -- assert.is_not_nil(compressed) + -- assert.are.equal(text, Inflate(compressed)) + -- end) + -- it("produces a zlib header", function() + -- local compressed = Deflate("some data to compress") + -- assert.are.equal(0x78, compressed:byte(1)) + -- end) + -- it("round-trips an empty string", function() + -- local compressed = Deflate("") + -- assert.is_not_nil(compressed) + -- assert.are.equal("", Inflate(compressed)) + -- end) + -- it("round-trips data larger than the 16k buffer", function() + -- local text = string.rep("The quick brown fox jumps over the lazy dog. ", 5000) + -- local compressed = Deflate(text) + -- assert.is_true(#compressed < #text) + -- assert.are.equal(text, Inflate(compressed)) + -- end) + -- it("round-trips binary data", function() + -- local bytes = {} + -- for i = 0, 255 do + -- bytes[i + 1] = string.char(i) + -- end + -- local text = table.concat(bytes) + -- assert.are.equal(text, Inflate(Deflate(text))) + -- end) + -- end) end) \ No newline at end of file diff --git a/spec/System/TestCompareBuySimilar_spec.lua b/spec/System/TestCompareBuySimilar_spec.lua index b7e28a66a0..6971ea6ec7 100644 --- a/spec/System/TestCompareBuySimilar_spec.lua +++ b/spec/System/TestCompareBuySimilar_spec.lua @@ -149,12 +149,13 @@ Implicits: 1 controls.mod1Check.state = true controls.mod1Check.changeFunc(true) controls.search.onClick() - local queryB64 = copiedUrl:match("Test%%20League/(.*)$"):gsub("%%(%x%x)", function(hex) - return string.char(tonumber(hex, 16)) - end) - local query = require("dkjson").decode(require("Classes.TradeHelpers").B64GzipDecode(queryB64)) + -- disabled for now due to headless wrapper lacking zlib bindings + -- local queryB64 = copiedUrl:match("Test%%20League/(.*)$"):gsub("%%(%x%x)", function(hex) + -- return string.char(tonumber(hex, 16)) + -- end) + -- local query = require("dkjson").decode(require("Classes.TradeHelpers").B64GzipDecode(queryB64)) - assert.same({ { type = "and", filters = { { id = "explicit.stat_1526933524" } } } }, query.stats) + -- assert.same({ { type = "and", filters = { { id = "explicit.stat_1526933524" } } } }, query.stats) end) it("rebuilds the URL when league and listed status change", function() @@ -166,14 +167,16 @@ Implicits: 1 controls.search.onClick() assert.not_equal(initialUrl, copiedUrl) assert.is_truthy(copiedUrl:find("/Standard/", 1, true)) - local standardUrl = copiedUrl - controls.listedDrop:SetSel(4) - controls.search.onClick() - assert.not_equal(standardUrl, copiedUrl) - local b64 = copiedUrl:match("Standard/(.-)$") - local json = require("Classes.TradeHelpers").B64GzipDecode(b64) - assert.is_truthy(json:find("any", 1, true)) + -- disabled for now due to headless wrapper lacking zlib bindings + -- local standardUrl = copiedUrl + + -- controls.listedDrop:SetSel(4) + -- controls.search.onClick() + -- assert.not_equal(standardUrl, copiedUrl) + -- local b64 = copiedUrl:match("Standard/(.-)$") + -- local json = require("Classes.TradeHelpers").B64GzipDecode(b64) + -- assert.is_truthy(json:find("any", 1, true)) end) it("persists popup selector choices", function() diff --git a/spec/System/TestTradeHelpers_spec.lua b/spec/System/TestTradeHelpers_spec.lua index b8d9bbf12e..a899cc219f 100644 --- a/spec/System/TestTradeHelpers_spec.lua +++ b/spec/System/TestTradeHelpers_spec.lua @@ -169,17 +169,18 @@ describe("TradeHelpers trade hash matching", function() assert.is_nil(tradeHelpers.findTradeIdOption("+100 to IQ", "explicit")) end) end) - describe("gzip decode", function() - local sampleText = "Test string please ignore" - local gzipped = tradeHelpers.B64GzipEncode(sampleText) - local roundTrip = tradeHelpers.B64GzipDecode(gzipped) - - assert.are.Equal(sampleText, roundTrip) - assert.are_not_equal(sampleText, gzipped) - - local longText = string.rep("12345678", 4096) - local gzippedLong = tradeHelpers.B64GzipEncode(longText) - local longRoundTrip = tradeHelpers.B64GzipDecode(gzippedLong) - assert.are.Equal(longText, longRoundTrip) - end) + -- disabled for now since the headless wrapper has no zlib bindings + -- it("gzip decode", function() + -- local sampleText = "Test string please ignore" + -- local gzipped = tradeHelpers.B64GzipEncode(sampleText) + -- local roundTrip = tradeHelpers.B64GzipDecode(gzipped) + + -- assert.are.Equal(sampleText, roundTrip) + -- assert.are_not_equal(sampleText, gzipped) + + -- local longText = string.rep("12345678", 4096) + -- local gzippedLong = tradeHelpers.B64GzipEncode(longText) + -- local longRoundTrip = tradeHelpers.B64GzipDecode(gzippedLong) + -- assert.are.Equal(longText, longRoundTrip) + -- end) end) diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 990c691747..dde26e9ef5 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -599,16 +599,20 @@ end ---@param str string String which will be encoded ----@return string result The given string, gzipped and then Base64URL encoded +---@return string? result The given string, gzipped and then Base64URL encoded function M.B64GzipEncode(str) local b64 = require("base64") - return b64.encode(Deflate(str, true)):gsub("%+", "-"):gsub("/", "_") + local deflated = Deflate(str, true) + if not deflated then return end + return b64.encode(deflated):gsub("%+", "-"):gsub("/", "_") end ---@param str string String which will be decoded ----@return string result The given string, Base64URL decoded and the ungzipped +---@return string? result The given string, Base64URL decoded and the ungzipped function M.B64GzipDecode(str) local b64 = require("base64") - return Inflate(b64.decode(str:gsub("%-", "+"):gsub("_", "/"))) + local data = b64.decode(str:gsub("%-", "+"):gsub("_", "/")) + if not data then return end + return Inflate(data) end return M diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 7f0de1df78..110c5454e1 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -105,7 +105,7 @@ end ---the search to fetch more items when the search cap (10k items) is reached ---@param league string ---@param query string ----@param callback fun(items:table, errMsg:string) +---@param callback fun(items: table, errMsg: string, query: string) ---@param params table @ params = { callbackQueryId = fun(queryId:string) } function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, query, callback, params) params = params or {} @@ -116,13 +116,17 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu -- Each repeat is a leap of 10k items, normally we shouldn't need more than 1-2 steps anyways local maxRecursion = 5 local currentRecursion = 0 + -- the query is adjusted as the search repeats, so return the final query + local function resultCallback(items, errMsg) + return callback(items, errMsg, query) + end local function performSearchCallback(response, errMsg) currentRecursion = currentRecursion + 1 if params.callbackQueryId and response and response.id then params.callbackQueryId(response.id) end if errMsg and ((errMsg == "No Matching Results Found" and currentRecursion >= maxRecursion) or errMsg ~= "No Matching Results Found") then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end if (response.total > self.maxFetchPerSearch and response.total < 10000) or currentRecursion >= maxRecursion then -- Search not clipped or max recursion reached, fetch results and finalize @@ -130,7 +134,7 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu -- Not enough items in the last search, fill results from previous search self:FetchResults(response.result, response.id, function(items, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end local fetchedItemIds = {} local idSet = {} @@ -163,18 +167,18 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu end self:FetchResults(unfetchedItemIds, previousSearchId, function(newItems, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end items = tableConcat(items, newItems) - callback(items, errMsg) + resultCallback(items, errMsg) end) else - callback(items, errMsg) + resultCallback(items, errMsg) end end) else -- Search not clipped and result count satisfy maxFetchPerSearch, proceed normally - self:FetchResults(response.result, response.id, callback) + self:FetchResults(response.result, response.id, resultCallback) end else if response.total < self.maxFetchPerSearch then -- Less than maximum items retrieved lower weight to try and get more. @@ -191,7 +195,7 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu local firstResultBatch = {unpack(response.result, 1, math.min(#response.result, 10))} self:FetchResults(firstResultBatch, response.id, function(items, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end previousSearchItems = items local highestWeight = items[1].weight @@ -476,11 +480,30 @@ function TradeQueryRequestsClass:SearchWithURL(url, callback) end league = paths[#paths-1] queryId = paths[#paths] - local queryIdDecoded = dkjson.decode(tradeHelpers.B64GzipDecode(queryId)) + local json = tradeHelpers.B64GzipDecode(queryId) + if not json then + return callback(nil, "URL is malformed") + end + local queryIdDecoded = dkjson.decode(json) + if not queryIdDecoded or type(queryIdDecoded.stats) ~= "table" then + return callback(nil, "URL is malformed") + end + -- the trader assumes that the first stat group will be a weight group + if queryIdDecoded.stats[1].type ~= "weight" then + for i, group in ipairs(queryIdDecoded.stats) do + -- swap a weight group to be the first group if it exists + if group.type == "weight" then + queryIdDecoded.stats[1], queryIdDecoded.stats[i] = queryIdDecoded.stats[i], queryIdDecoded.stats[1] + break + end + end + end + if queryIdDecoded.stats[1].type ~= "weight" then + return callback(nil, "Trade search URL is not a weight search") + end local newQuery = { - query = queryIdDecoded or {}, + query = queryIdDecoded, sort = { ["statgroup.0"] = "desc" }, - engine = "new" } self:SearchWithQueryWeightAdjusted(realm, league, dkjson.encode(newQuery), callback) end