From 4073f7e82c36277b5ccf50a8806218e69eacbec1 Mon Sep 17 00:00:00 2001 From: Ryan Liptak Date: Fri, 23 Feb 2018 01:27:31 -0800 Subject: [PATCH] Big update - Drop support for versions < 1.13, as I don't want to install that many versions of d2 to get the offsets - Add config with a few options - Add support for ignoring time spent in town past a certain threshold, to allow for stash management without ruining xp/min (default is 20 seconds) - Time spent paused is now ignored for game-time xp/min calculations - Add run tracking and # runs till level - Reformat console output to be more readable/concise - Add /players x value to output - General improvements/bug fixes/refactoring - Add a bunch of stuff for calculating real xp gain (area level, player level pentalty, /players x), but don't have a good way of displaying it yet and its not quite done (relevant console output is disabled by default) - Now depends on luabitop --- .gitignore | 2 + README.md | 49 +++++----- d2info.lua | 55 ++---------- d2info/config.lua | 97 ++++++++++++++++++++ d2info/constants.lua | 180 ++++++++++++++++++++++++++++++++++++- d2info/d2reader.lua | 93 ++++++++++++++++++- d2info/gamestate.lua | 90 +++++++++++++++++++ d2info/offsets.lua | 43 ++++++--- d2info/output.lua | 92 +++++++++++++++---- d2info/session.lua | 70 ++++++++++++--- d2info/utils.lua | 51 ++++++++++- scripts/build-luastatic.sh | 17 +++- 12 files changed, 717 insertions(+), 122 deletions(-) create mode 100644 d2info/config.lua create mode 100644 d2info/gamestate.lua diff --git a/.gitignore b/.gitignore index f470536..f92922d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ output +d2info-config.lua +d2info-config-default.lua # Created by https://www.gitignore.io/api/lua,linux,windows diff --git a/README.md b/README.md index 9122975..cf343b4 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,26 @@ Currently, it provides the following information: - Experience per minute over different time periods: - Real time (since the character was first seen by the d2info process) - - In-game time (only includes time that the character is in a game) + - In-game time (only includes time that the character is in a game, not paused, etc) - Current game - Last game (xp/min of the last game at the point of save+quit) - Estimated time until the next level, using the various exp/min readings +- Number of runs finished, average xp/min per run, estimated runs until the next level - Number of experience 'ticks' gained (pixels filled in the experience bar) +- Information about the current area, like monster level and % xp gain (unfinished, disabled by default; see `SHOW_AREA_INFORMATION` in the config) -Supports Diablo II verisons 1.11, 1.11b, 1.12, 1.13c, 1.13d, 1.14c, and 1.14d +Supports Diablo II verisons 1.13c, 1.13d, 1.14b, 1.14c, and 1.14d ## Installation Simply grab the [latest .exe build from the releases page](https://github.com/squeek502/d2info/releases/latest) and run it. +*Note: You'll probably want to put the .exe in its own folder, as it will output various files relative to its location* + ## Running using Lua - Clone this repository -- Build [memreader](https://github.com/squeek502/memreader), [sleep](https://github.com/squeek502/sleep), and [luafilesystem](https://github.com/keplerproject/luafilesystem) and make the resulting .dll's available to Lua's `package.cpath`. +- Build [memreader](https://github.com/squeek502/memreader), [sleep](https://github.com/squeek502/sleep), [luafilesystem](https://github.com/keplerproject/luafilesystem), and [LuaBitOp](http://bitop.luajit.org/) and make the resulting .dll's available to Lua's `package.cpath`. - Run `lua d2info.lua` ## Output @@ -32,26 +36,25 @@ The information is output to both the console window and to individual text file Console output example: ``` -CoolGuy - -Overall (real-time): 167.3k xp/min -Overall (game-time): 183.2k xp/min -Current game: 87.5k xp/min -Last game: 258.7k xp/min - -Est time until level 96: - 12h (using real-time xp/min) - 11h (using game-time xp/min) - 23h (using current game's xp/min) - 8h22m (using last game's xp/min) - -Exp gained (overall): 2.4m -Exp gained (current game): 179.8k -Exp gained (last game): 156.1k - -Ticks gained (overall): 1.4 -Ticks gained (current game): 0.1 -Ticks gained (last game): 0.1 +CharName (level 96 & 32.85%) +/players 8 + +Run #6: + 207.8k xp/min (734.2k xp in 3m32s) + 13h until level 97 at this rate + +Last run: + 160.7k xp/min (576.0k xp in 3m35s) + 17h until level 97 at this rate + +Average run: + 165.0k xp/min (521.0k xp in 3m09s) + 315 runs until level 97 + +This session: + +1.6 ticks (+1.36%) + 177.8k xp/min (3.3m xp in 19m19s) + 15h until level 97 at this rate ``` ## Acknowledgements diff --git a/d2info.lua b/d2info.lua index 592419a..b2b041f 100644 --- a/d2info.lua +++ b/d2info.lua @@ -1,57 +1,18 @@ local memreader = require('memreader') -local D2Reader = require('d2info.d2reader') local sleep = require('sleep') -local Session = require('d2info.session') +local D2Reader = require('d2info.d2reader') local Output = require('d2info.output') +local Config = require('d2info.config') +local GameState = require('d2info.gamestate') memreader.debugprivilege(true) -local reader = D2Reader.new() -local sessions = {} -local output = Output.new() -local lastInfo = {} -local UPDATE_PERIOD = 1000 - -while true do - local player = reader:getPlayerName() - local exp, lvl = reader:getExperience() - if player and exp then - if not sessions[player] then - sessions[player] = {} - sessions[player].total = Session.new(exp, lvl) - end - if sessions[player].current == nil then - sessions[player].current = Session.new(exp, lvl) - end - local current, total, last = sessions[player].current, sessions[player].total, sessions[player].last - current:update(exp, lvl) - total:update(exp, lvl) +local reader, output, config = D2Reader.new(), Output.new(), Config.new() +local state = GameState.new(reader, config, output) - output:toScreen(player, lvl, total, current, last) - output:toFile(player, lvl, total, current, last) +local UPDATE_PERIOD = config:get("UPDATE_PERIOD") - current:incrementDuration() - total:incrementDuration() - - lastInfo.player = player - lastInfo.level = lvl - elseif reader.status ~= nil then - os.execute('cls') - print(reader.status) - else - os.execute('cls') - print("No player") - - if lastInfo.player ~= nil and sessions[lastInfo.player].current ~= nil then - sessions[lastInfo.player].last = sessions[lastInfo.player].current - sessions[lastInfo.player].current = nil - end - - -- need to update files here because otherwise they wouldn't update - -- while at the menu screen during save+quit - if lastInfo.player then - output:toFile(lastInfo.player, lastInfo.level, sessions[lastInfo.player].total, sessions[lastInfo.player].current, sessions[lastInfo.player].last) - end - end +while true do + state:tick(UPDATE_PERIOD) sleep(UPDATE_PERIOD) end diff --git a/d2info/config.lua b/d2info/config.lua new file mode 100644 index 0000000..b435c2e --- /dev/null +++ b/d2info/config.lua @@ -0,0 +1,97 @@ +local lfs = require('lfs') + +local DEFAULT_CONFIG_NOTE = [=[--[[ + + NOTE: This file is not used by d2info and will get overwritten on every run of d2info. + + Instead, it is intended to be used as a reference for updating your config + file after updating to a new version of d2info (to see new config options, etc). + +]]-- + +]=] +local DEFAULT_CONFIG = [[ +return { + -- time between updates, in milliseconds + UPDATE_PERIOD = 1000, + + -- maximum duration spent in each town that will count towards exp/min calculations + -- (e.g. with a value of 30, spending longer than 30 seconds in any given town will only + -- count as 30 seconds when calculating exp/min using game time) + -- this allows for doing things like stash management without affecting the stats too much + MAX_TOWN_DURATION = 20, + + -- enable/disable outputting info to the console screen + OUTPUT_TO_SCREEN = true, + + -- enable/disable outputting info to files + OUTPUT_TO_FILE = true, + + -- show information about the area you are currently in, such as: area level, + -- percentage xp gain from monsters/champions/uniques in that area, etc + SHOW_AREA_INFORMATION = false, +} +]] + +local Config = {} +Config.__index = Config + +function Config.new(file, defaultFile) + local self = setmetatable({}, Config) + self.default = assert(loadstring(DEFAULT_CONFIG))() + self.config = {} + self.file = file or "d2info-config.lua" + self.defaultFile = defaultFile or "d2info-config-default.lua" + self:load(file) + self:write(self.defaultFile, DEFAULT_CONFIG_NOTE .. DEFAULT_CONFIG) + return self +end + +local function resolve(t, ...) + local keys = {...} + local key = table.remove(keys, 1) + if #keys == 0 then + return t[key] + end + if not t[key] then + return nil + end + return resolve(t[key], keys) +end + +-- Gets a config value by resolving its keys in order +-- e.g. get('a', 'b') will return config['a']['b'] +function Config:get(...) + local v = resolve(self.config, ...) + if v == nil then + v = resolve(self.default, ...) + end + return v +end + +function Config:load(file) + if not file then file = self.file end + if not self:exists(file) then + self:write(file, DEFAULT_CONFIG) + return + end + local f = assert(io.open(file)) + local str = f:read("*all") + local fn = assert(loadstring(str, file)) + local loaded = fn() + self.config = loaded +end + +function Config:write(file, data) + if not file then file = self.file end + local f = assert(io.open(file, "w")) + f:write(data) + f:close() +end + +function Config:exists(file) + if not file then file = self.file end + return lfs.attributes(file) ~= nil +end + +return Config diff --git a/d2info/constants.lua b/d2info/constants.lua index c79403c..774813a 100644 --- a/d2info/constants.lua +++ b/d2info/constants.lua @@ -229,5 +229,183 @@ return { 2962400612, 3229426756, 3520485254 - } + }, + + experienceLevelPenalties = { + [70] = 0.9531, + [71] = 0.9063, + [72] = 0.8594, + [73] = 0.8125, + [74] = 0.7656, + [75] = 0.7188, + [76] = 0.6719, + [77] = 0.6250, + [78] = 0.5781, + [79] = 0.5313, + [80] = 0.4844, + [81] = 0.4375, + [82] = 0.3906, + [83] = 0.3438, + [84] = 0.2969, + [85] = 0.2500, + [86] = 0.1875, + [87] = 0.1406, + [88] = 0.1055, + [89] = 0.0791, + [90] = 0.0596, + [91] = 0.0449, + [92] = 0.0342, + [93] = 0.0254, + [94] = 0.0195, + [95] = 0.0146, + [96] = 0.0107, + [97] = 0.0078, + [98] = 0.0059, + }, + + difficulties = { + -- id -> difficulty + [0] = {name="Normal", id=0, code="normal"}, + [1] = {name="Nightmare", id=1, code="nightmare"}, + [2] = {name="Hell", id=2, code="hell"}, + -- code -> id + normal = 0, + nightmare = 1, + hell = 2, + }, + + areas = { + {id=1, act=1, name="Rogue Encampment", town=true, alvl={normal=0, nightmare=0, hell=0}}, + {id=2, act=1, name="Blood Moor", alvl={normal=1, nightmare=36, hell=67}}, + {id=3, act=1, name="Cold Plains", alvl={normal=2, nightmare=36, hell=68}}, + {id=4, act=1, name="Stony Field", alvl={normal=4, nightmare=37, hell=68}}, + {id=5, act=1, name="Dark Wood", alvl={normal=5, nightmare=38, hell=68}}, + {id=6, act=1, name="Black Marsh", alvl={normal=6, nightmare=38, hell=69}}, + {id=7, act=1, name="Tamoe Highland", alvl={normal=8, nightmare=39, hell=69}}, + {id=8, act=1, name="Den of Evil", alvl={normal=1, nightmare=36, hell=79}}, + {id=9, act=1, name="Cave Level 1", alvl={normal=2, nightmare=36, hell=77}}, + {id=10, act=1, name="Underground Passage Level 1", alvl={normal=4, nightmare=37, hell=69}}, + {id=11, act=1, name="Hole Level 1", alvl={normal=5, nightmare=38, hell=80}}, + {id=12, act=1, name="Pit Level 1", alvl={normal=7, nightmare=39, hell=85}}, + {id=13, act=1, name="Cave Level 2", alvl={normal=2, nightmare=37, hell=78}}, + {id=14, act=1, name="Underground Passage Level 2", alvl={normal=4, nightmare=38, hell=83}}, + {id=15, act=1, name="Hole Level 2", alvl={normal=5, nightmare=39, hell=81}}, + {id=16, act=1, name="Pit Level 2", alvl={normal=7, nightmare=40, hell=85}}, + {id=17, act=1, name="Burial Grounds", alvl={normal=3, nightmare=36, hell=80}}, + {id=18, act=1, name="Crypt", alvl={normal=3, nightmare=37, hell=83}}, + {id=19, act=1, name="Mausoleum", alvl={normal=3, nightmare=37, hell=85}}, + {id=20, act=1, name="Forgotten Tower", alvl={normal=0, nightmare=0, hell=0}}, + {id=21, act=1, name="Tower Cellar Level 1", alvl={normal=7, nightmare=38, hell=75}}, + {id=22, act=1, name="Tower Cellar Level 2", alvl={normal=7, nightmare=39, hell=76}}, + {id=23, act=1, name="Tower Cellar Level 3", alvl={normal=7, nightmare=40, hell=77}}, + {id=24, act=1, name="Tower Cellar Level 4", alvl={normal=7, nightmare=41, hell=78}}, + {id=25, act=1, name="Tower Cellar Level 5", alvl={normal=7, nightmare=42, hell=79}}, + {id=26, act=1, name="Monastery Gate", alvl={normal=8, nightmare=40, hell=70}}, + {id=27, act=1, name="Outer Cloister", alvl={normal=9, nightmare=40, hell=70}}, + {id=28, act=1, name="Barracks", alvl={normal=9, nightmare=40, hell=70}}, + {id=29, act=1, name="Jail Level 1", alvl={normal=10, nightmare=41, hell=71}}, + {id=30, act=1, name="Jail Level 2", alvl={normal=10, nightmare=41, hell=71}}, + {id=31, act=1, name="Jail Level 3", alvl={normal=10, nightmare=41, hell=71}}, + {id=32, act=1, name="Inner Cloister", alvl={normal=10, nightmare=41, hell=72}}, + {id=33, act=1, name="Cathedral", alvl={normal=11, nightmare=42, hell=72}}, + {id=34, act=1, name="Catacombs Level 1", alvl={normal=11, nightmare=42, hell=72}}, + {id=35, act=1, name="Catacombs Level 2", alvl={normal=11, nightmare=42, hell=73}}, + {id=36, act=1, name="Catacombs Level 3", alvl={normal=12, nightmare=43, hell=73}}, + {id=37, act=1, name="Catacombs Level 4", alvl={normal=12, nightmare=43, hell=73}}, + {id=38, act=1, name="Tristram", alvl={normal=6, nightmare=39, hell=76}}, + {id=39, act=1, name="Moo Moo Farm", alvl={normal=28, nightmare=64, hell=81}}, + {id=40, act=2, name="Lut Gholein", town=true, alvl={normal=0, nightmare=0, hell=0}}, + {id=41, act=2, name="Rocky Waste", alvl={normal=14, nightmare=43, hell=75}}, + {id=42, act=2, name="Dry Hills", alvl={normal=15, nightmare=44, hell=76}}, + {id=43, act=2, name="Far Oasis", alvl={normal=16, nightmare=45, hell=76}}, + {id=44, act=2, name="Lost City", alvl={normal=17, nightmare=46, hell=77}}, + {id=45, act=2, name="Valley of Snakes", alvl={normal=18, nightmare=46, hell=77}}, + {id=46, act=2, name="Canyon of the Magi", alvl={normal=16, nightmare=48, hell=79}}, + {id=47, act=2, name="Sewers Level 1", alvl={normal=13, nightmare=43, hell=74}}, + {id=48, act=2, name="Sewers Level 2", alvl={normal=13, nightmare=43, hell=74}}, + {id=49, act=2, name="Sewers Level 3", alvl={normal=14, nightmare=44, hell=75}}, + {id=50, act=2, name="Harem Level 1", alvl={normal=0, nightmare=0, hell=0}}, + {id=51, act=2, name="Harem Level 2", alvl={normal=13, nightmare=47, hell=78}}, + {id=52, act=2, name="Palace Cellar Level 1", alvl={normal=13, nightmare=47, hell=78}}, + {id=53, act=2, name="Palace Cellar Level 2", alvl={normal=13, nightmare=47, hell=78}}, + {id=54, act=2, name="Palace Cellar Level 3", alvl={normal=13, nightmare=48, hell=78}}, + {id=55, act=2, name="Stony Tomb Level 1", alvl={normal=12, nightmare=44, hell=78}}, + {id=56, act=2, name="Halls of the Dead Level 1", alvl={normal=12, nightmare=44, hell=79}}, + {id=57, act=2, name="Halls of the Dead Level 2", alvl={normal=13, nightmare=45, hell=81}}, + {id=58, act=2, name="Claw Viper Temple Level 1", alvl={normal=14, nightmare=47, hell=82}}, + {id=59, act=2, name="Stony Tomb Level 2", alvl={normal=12, nightmare=44, hell=79}}, + {id=60, act=2, name="Halls of the Dead Level 3", alvl={normal=13, nightmare=45, hell=82}}, + {id=61, act=2, name="Claw Viper Temple Level 2", alvl={normal=14, nightmare=47, hell=83}}, + {id=62, act=2, name="Maggot Lair Level 1", alvl={normal=17, nightmare=45, hell=84}}, + {id=63, act=2, name="Maggot Lair Level 2", alvl={normal=17, nightmare=45, hell=84}}, + {id=64, act=2, name="Maggot Lair Level 3", alvl={normal=17, nightmare=46, hell=85}}, + {id=65, act=2, name="Ancient Tunnels", alvl={normal=17, nightmare=46, hell=85}}, + {id=66, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=67, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=68, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=69, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=70, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=71, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=72, act=2, name="Tal Rasha's Tomb", alvl={normal=17, nightmare=49, hell=80}}, + {id=73, act=2, name="Duriel's Lair", alvl={normal=17, nightmare=49, hell=80}}, + {id=74, act=2, name="Arcane Sanctuary", alvl={normal=14, nightmare=48, hell=79}}, + {id=75, act=3, name="Kurast Docks", town=true, alvl={normal=0, nightmare=0, hell=0}}, + {id=76, act=3, name="Spider Forest", alvl={normal=21, nightmare=49, hell=79}}, + {id=77, act=3, name="Great Marsh", alvl={normal=21, nightmare=50, hell=80}}, + {id=78, act=3, name="Flayer Jungle", alvl={normal=22, nightmare=50, hell=80}}, + {id=79, act=3, name="Lower Kurast", alvl={normal=22, nightmare=52, hell=80}}, + {id=80, act=3, name="Kurast Bazaar", alvl={normal=22, nightmare=52, hell=81}}, + {id=81, act=3, name="Upper Kurast", alvl={normal=23, nightmare=52, hell=81}}, + {id=82, act=3, name="Kurast Causeway", alvl={normal=24, nightmare=53, hell=81}}, + {id=83, act=3, name="Travincal", alvl={normal=24, nightmare=54, hell=82}}, + {id=84, act=3, name="Arachnid Lair", alvl={normal=21, nightmare=50, hell=79}}, + {id=85, act=3, name="Spider Cavern", alvl={normal=21, nightmare=50, hell=79}}, + {id=86, act=3, name="Swampy Pit Level 1", alvl={normal=21, nightmare=51, hell=80}}, + {id=87, act=3, name="Swampy Pit Level 2", alvl={normal=21, nightmare=51, hell=81}}, + {id=88, act=3, name="Flayer Dungeon Level 1", alvl={normal=22, nightmare=51, hell=81}}, + {id=89, act=3, name="Flayer Dungeon Level 2", alvl={normal=22, nightmare=51, hell=82}}, + {id=90, act=3, name="Swampy Pit Level 3", alvl={normal=21, nightmare=51, hell=82}}, + {id=91, act=3, name="Flayer Dungeon Level 3", alvl={normal=22, nightmare=51, hell=83}}, + {id=92, act=3, name="Sewers Level 1", alvl={normal=23, nightmare=52, hell=84}}, + {id=93, act=3, name="Sewers Level 2", alvl={normal=24, nightmare=53, hell=85}}, + {id=94, act=3, name="Ruined Temple", alvl={normal=23, nightmare=53, hell=84}}, + {id=95, act=3, name="Disused Fane", alvl={normal=23, nightmare=53, hell=84}}, + {id=96, act=3, name="Forgotten Reliquary", alvl={normal=23, nightmare=53, hell=84}}, + {id=97, act=3, name="Forgotten Temple", alvl={normal=24, nightmare=54, hell=85}}, + {id=98, act=3, name="Ruined Fane", alvl={normal=24, nightmare=54, hell=85}}, + {id=99, act=3, name="Disused Reliquary", alvl={normal=24, nightmare=54, hell=85}}, + {id=100, act=3, name="Durance of Hate Level 1", alvl={normal=25, nightmare=55, hell=83}}, + {id=101, act=3, name="Durance of Hate Level 2", alvl={normal=25, nightmare=55, hell=83}}, + {id=102, act=3, name="Durance of Hate Level 3", alvl={normal=25, nightmare=55, hell=83}}, + {id=103, act=4, name="The Pandemonium Fortress", town=true, alvl={normal=0, nightmare=0, hell=0}}, + {id=104, act=4, name="Outer Steppes", alvl={normal=26, nightmare=56, hell=82}}, + {id=105, act=4, name="Plains of Despair", alvl={normal=26, nightmare=56, hell=83}}, + {id=106, act=4, name="City of the Damned", alvl={normal=27, nightmare=57, hell=84}}, + {id=107, act=4, name="River of Flame", alvl={normal=27, nightmare=57, hell=85}}, + {id=108, act=4, name="Chaos Sanctuary", alvl={normal=28, nightmare=58, hell=85}}, + {id=109, act=5, name="Harrogath", town=true, alvl={normal=0, nightmare=0, hell=0}}, + {id=110, act=5, name="Bloody Foothills", alvl={normal=24, nightmare=58, hell=80}}, + {id=111, act=5, name="Frigid Highlands", alvl={normal=25, nightmare=59, hell=81}}, + {id=112, act=5, name="Arreat Plateau", alvl={normal=26, nightmare=60, hell=81}}, + {id=113, act=5, name="Crystalline Passage", alvl={normal=29, nightmare=61, hell=82}}, + {id=114, act=5, name="Frozen River", alvl={normal=29, nightmare=61, hell=83}}, + {id=115, act=5, name="Glacial Trail", alvl={normal=29, nightmare=61, hell=83}}, + {id=116, act=5, name="Drifter Cavern", alvl={normal=29, nightmare=61, hell=84}}, + {id=117, act=5, name="Tundra Wastelands", alvl={normal=27, nightmare=60, hell=81}}, + {id=118, act=5, name="Ancients' Way", alvl={normal=29, nightmare=62, hell=82}}, + {id=119, act=5, name="Icy Cellar", alvl={normal=29, nightmare=62, hell=83}}, + {id=120, act=5, name="Arreat Summit", alvl={normal=37, nightmare=68, hell=87}}, + {id=121, act=5, name="Nihlathak's Temple", alvl={normal=32, nightmare=63, hell=83}}, + {id=122, act=5, name="Halls of Anguish", alvl={normal=33, nightmare=63, hell=83}}, + {id=123, act=5, name="Halls of Pain", alvl={normal=34, nightmare=64, hell=84}}, + {id=124, act=5, name="Halls of Vaught", alvl={normal=36, nightmare=64, hell=84}}, + {id=125, act=5, name="Abaddon", alvl={normal=39, nightmare=60, hell=81}}, + {id=126, act=5, name="Pit of Acheron", alvl={normal=39, nightmare=61, hell=82}}, + {id=127, act=5, name="Infernal Pit", alvl={normal=39, nightmare=62, hell=83}}, + {id=128, act=5, name="The Worldstone Keep Level 1", alvl={normal=39, nightmare=65, hell=85}}, + {id=129, act=5, name="The Worldstone Keep Level 2", alvl={normal=40, nightmare=65, hell=85}}, + {id=130, act=5, name="The Worldstone Keep Level 3", alvl={normal=42, nightmare=66, hell=85}}, + {id=131, act=5, name="Throne of Destruction", alvl={normal=43, nightmare=66, hell=85}}, + {id=132, act=5, name="The Worldstone Chamber", alvl={normal=43, nightmare=66, hell=85}}, + }, } diff --git a/d2info/d2reader.lua b/d2info/d2reader.lua index 39fcbd3..010a4b6 100644 --- a/d2info/d2reader.lua +++ b/d2info/d2reader.lua @@ -3,8 +3,9 @@ local constants = require('d2info.constants') local utils = require('d2info.utils') local offsetsTable = require('d2info.offsets') local binary = require('d2info.binary') +local bit = require('bit') -local uint32, uint16 = binary.decode_uint32, binary.decode_uint16 +local uint32, uint16, uint8 = binary.decode_uint32, binary.decode_uint16, binary.decode_uint8 local D2Reader = {} D2Reader.__index = D2Reader @@ -16,6 +17,8 @@ function D2Reader.new() self.status = "Initializing" self.isPlugY = nil self.d2ClientDLL = nil + self.d2GameDLL = nil + self.d2NetDLL = nil self:init() return self end @@ -27,20 +30,22 @@ function D2Reader:init() return end - local version, err = self.process:version() - if not version then + local versionInfo, err = self.process:version() + if not versionInfo then self.status = "Error obtaining Game.exe version: " .. err self.process = nil return end - version = utils.friendlyVersion(version.file) + local version = utils.friendlyVersion(versionInfo.file) local d2version = constants.versions[version] if not d2version then self.status = "Error: Unrecognized Game.exe version: " .. version return end + self.isPlugY = false + self.d2ClientDLL, self.d2GameDLL, self.d2NetDLL = nil, nil, nil for mod in self.process:modules() do if string.lower(mod.name) == "plugy.dll" then self.isPlugY = true @@ -48,6 +53,18 @@ function D2Reader:init() if string.lower(mod.name) == "d2client.dll" then self.d2ClientDLL = mod end + if string.lower(mod.name) == "d2game.dll" then + self.d2GameDLL = mod + end + if string.lower(mod.name) == "d2net.dll" then + self.d2NetDLL = mod + end + end + + local hasDLLs = self.d2ClientDLL and self.d2GameDLL and self.d2NetDLL + if versionInfo.file.minor == 0 and not hasDLLs then + self.status = "Waiting for D2 dlls to be loaded" + return end self.base = self.d2ClientDLL and self.d2ClientDLL.base or self.process.base @@ -88,11 +105,16 @@ end function D2Reader:onExit() self.process = nil + self.isPlugY = false + self.d2ClientDLL, self.d2GameDLL, self.d2NetDLL = nil, nil, nil + self.base = nil + self.offsets = nil end function D2Reader:checkStatus() if self.process and self.process:exitcode() then self:onExit() + return end if not self.process or self.status ~= nil then self:init() @@ -152,4 +174,67 @@ function D2Reader:getExperience() end end +function D2Reader:getArea() + if not self:checkStatus() then return end + local area = string.byte(assert(self.process:read(self.base + self.offsets.area, 1))) + -- this will return nil when not in a game (area = 0) + return constants.areas[area] +end + +function D2Reader:getDifficulty() + if not self:checkStatus() then return end + local gamePtr = self:getGamePointer() + if gamePtr then + local difficulty = uint8(assert(self.process:read(gamePtr + self.offsets.gameDifficulty, 2))) + return constants.difficulties[difficulty] + end +end + +function D2Reader:getPlayersX() + if not self:checkStatus() then return end + local gameBase = self.d2GameDLL and self.d2GameDLL.base or self.base + local value = uint8(assert(self.process:read(gameBase + self.offsets.playersX, 2))) + -- a value of 0 means that the setting hasn't been set by + -- /playersX since D2 started, so the real value is the default of 1 + return value ~= 0 and value or 1 +end + +function D2Reader:getWorldPointer() + if not self:checkStatus() then return end + local gameBase = self.d2GameDLL and self.d2GameDLL.base or self.base + local data, err = self.process:read(gameBase + self.offsets.world, 4) + -- treat memory read error here as non-fatal, as this can occur + -- when the dlls are unloading at game shutdown + if err then return nil, err end + local worldPtr = uint32(data) + return worldPtr ~= 0 and worldPtr or nil +end + +function D2Reader:getGamePointer() + if not self:checkStatus() then return end + local netBase = self.d2NetDLL and self.d2NetDLL.base or self.base + + local worldPtr = self:getWorldPointer() + if worldPtr then + local gameId = uint32(assert(self.process:read(netBase + self.offsets.gameId, 4))) + local gameMask = uint32(assert(self.process:read(worldPtr + self.offsets.worldGameMask, 4))) + local gameIndex = bit.band(gameId, gameMask) + local gameOffset = gameIndex * 0x0C + 0x08 + local gameBuffer = uint32(assert(self.process:read(worldPtr + self.offsets.worldGameBuffer, 4))) + local gamePtr = uint32(assert(self.process:read(gameBuffer + gameOffset, 4))) + -- if the sign bit is set, then the pointer is invalid + local valid = bit.band(gamePtr, 0x80000000) == 0 + return (valid and gamePtr ~= 0) and gamePtr or nil + end +end + +function D2Reader:getCurrentFrameNumber() + if not self:checkStatus() then return end + local gamePtr = self:getGamePointer() + if gamePtr then + local currentFrame = uint32(assert(self.process:read(gamePtr + self.offsets.gameCurrentFrame, 4))) + return currentFrame + end +end + return D2Reader diff --git a/d2info/gamestate.lua b/d2info/gamestate.lua new file mode 100644 index 0000000..c0e2254 --- /dev/null +++ b/d2info/gamestate.lua @@ -0,0 +1,90 @@ +local Session = require('d2info.session') + +local GameState = {} +GameState.__index = GameState + +function GameState.new(reader, config, output) + local self = setmetatable({}, GameState) + + self.reader = reader + self.config = config + self.output = output + self.sessions = {} + self.ingame = false + + return self +end + +function GameState:inValidGame() + return self.reader:getGamePointer() ~= nil and self.reader:getExperience() ~= nil +end + +function GameState:inTown() + return self.area and self.area.town == true +end + +function GameState:isPaused() + return self.ingame and self.lastFrameNumber and self.lastFrameNumber == self.frameNumber +end + +function GameState:setupCurrentSession() + assert(self.player, "attempt to setup session for nil player") + local maxTownDuration = self.config:get("MAX_TOWN_DURATION") + if not self.sessions[self.player] then + self.sessions[self.player] = {} + self.sessions[self.player].total = Session.new(self.exp, self.level, maxTownDuration) + end + if self.sessions[self.player].current == nil then + self.sessions[self.player].current = Session.new(self.exp, self.level, maxTownDuration) + end +end + +function GameState:onCurrentSessionEnd() + local current = self.sessions[self.player].current + self.sessions[self.player].total:onGameEnd(current) + self.sessions[self.player].last = current + self.sessions[self.player].current = nil +end + +function GameState:currentSessionExists() + return self.player ~= nil and self.sessions[self.player].current ~= nil +end + +function GameState:getSessions() + if self.ingame then + return self.sessions[self.player] + end +end + +function GameState:tick(ms) + self.ingame = self:inValidGame() + if self.ingame then + -- read current state + self.player = self.reader:getPlayerName() + self.exp, self.level = self.reader:getExperience() + self.difficulty = self.reader:getDifficulty() + self.area = self.reader:getArea() + self.playersX = self.reader:getPlayersX() + self.lastFrameNumber = self.frameNumber + self.frameNumber = self.reader:getCurrentFrameNumber() + + self:setupCurrentSession() + + local current, total = self.sessions[self.player].current, self.sessions[self.player].total + current:update(ms / 1000, self) + total:update(ms / 1000, self) + else + if self:currentSessionExists() then + self:onCurrentSessionEnd() + end + end + + if self.config:get("OUTPUT_TO_SCREEN") then + self.output:toScreen(self) + end + if self.config:get("OUTPUT_TO_FILE") then + self.output:toFile(self) + end +end + +return GameState diff --git a/d2info/offsets.lua b/d2info/offsets.lua index 3902013..bff4f8c 100644 --- a/d2info/offsets.lua +++ b/d2info/offsets.lua @@ -1,29 +1,48 @@ return { ["1.14d"] = { - player=0x003A5E74, -- relative to base address + player=0x003A5E74, -- relative to (base|client.dll) address + area=0x003A3140, -- relative to (base|client.dll) address + gameId=0x00482D0C, -- relative to (base|net.dll) address + world=0x00483D38, -- relative to (base|game.dll) address + playersX=0x00483D70, -- relative to (base|game.dll) address }, ["1.14c"] = { player=0x0039CEFC, - }, - ["1.13c"] = { - player=0x0011BBFC, + area=0x0039A1C8, + gameId=0x00479C94, + world=0x0047ACC0, + playersX=0x0047ACF8, + }, + ["1.14b"] = { + player=0x0039DEFC, + area=0x0039B1C8, + gameId=0x0047AD4C, + world=0x0047BD78, + playersX=0x0047BDB0, }, ["1.13d"] = { player=0x0011D050, + area=0x0008F66C, + gameId=0x0000B420, + world=0x00111C10, + playersX=0x00111C44, }, - ["1.12"] = { - player=0x0011C3D0, - }, - ["1.11b"] = { - player=0x0011C1E0, - }, - ["1.11"] = { - player=0x0011C4F0, + ["1.13c"] = { + player=0x0011BBFC, + area=0x0011C310, + gameId=0x0000B428, + world=0x00111C24, + playersX=0x00111C1C, }, ["common"] = { playerData=0x14, -- relative to player address playerName=0x00, -- relative to playerData address statList=0x5C, -- relative to player address fullStats=0x48, -- relative to statList address + statListGamePointer=0x60, -- relative to statList address + gameDifficulty=0x6D, -- relative to game address + gameCurrentFrame=0xA8, -- relative to game address + worldGameBuffer=0x1C, -- relative to world address + worldGameMask=0x24, -- relative to world address }, } diff --git a/d2info/output.lua b/d2info/output.lua index 0cbd1bf..e3cdf0c 100644 --- a/d2info/output.lua +++ b/d2info/output.lua @@ -1,6 +1,7 @@ local utils = require('d2info.utils') local lfs = require('lfs') local printf, friendlyNumber, friendlyTime, toFile = utils.printf, utils.friendlyNumber, utils.friendlyTime, utils.toFile +local expToPercentLeveled = utils.expToPercentLeveled local Output = {} Output.__index = Output @@ -14,27 +15,70 @@ function Output.new(outputDir) return self end -function Output:toScreen(player, level, total, current, last) +function Output:toScreen(state) os.execute('cls') - printf("%s\n", player) - printf("Overall (real-time): %s xp/min", friendlyNumber(total:realTimeExpPerMin())) - printf("Overall (game-time): %s xp/min", friendlyNumber(total:durationExpPerMin())) - printf("Current game: %s xp/min", current and friendlyNumber(current:realTimeExpPerMin()) or "-") - printf("Last game: %s xp/min", last and friendlyNumber(last:durationExpPerMin()) or "-") - printf("\nEst time until level %d:", level+1) - printf(" %s (using real-time xp/min)", friendlyTime(total:realTimeToNextLevel())) - printf(" %s (using game-time xp/min)", friendlyTime(total:gameTimeToNextLevel())) - printf(" %s (using current game's xp/min)", current and friendlyTime(current:gameTimeToNextLevel()) or "-") - printf(" %s (using last game's xp/min)", last and friendlyTime(last:gameTimeToNextLevel()) or "-") - printf("\nExp gained (overall): %s", friendlyNumber(total:expGained())) - printf("Exp gained (current game): %s", current and friendlyNumber(current:expGained()) or "-") - printf("Exp gained (last game): %s", last and friendlyNumber(last:expGained()) or "-") - printf("\nTicks gained (overall): %0.1f", total:ticksGained()) - printf("Ticks gained (current game): %0.1f", current and current:ticksGained() or 0) - printf("Ticks gained (last game): %0.1f", last and last:ticksGained() or 0) + if state.ingame then + local sessions = state:getSessions() + local total, current, last = sessions.total, sessions.current, sessions.last + printf("%s (level %d & %.2f%%)", state.player, state.level, expToPercentLeveled(state.exp, state.level)*100) + printf("/players %d", state.playersX) + + if state:isPaused() then + print("\n[PAUSED]") + end + + if current then + printf("\nRun #%d:", total.runs+1) + printf(" %s xp/min (%s xp in %s)", friendlyNumber(current:durationExpPerMin()), friendlyNumber(current:expGained()), friendlyTime(current:getAdjustedGameTime())) + printf(" %s until level %d at this rate", friendlyTime(current:gameTimeToNextLevel()), state.level+1) + end + + if last then + printf("\nLast run:") + printf(" %s xp/min (%s xp in %s)", friendlyNumber(last:durationExpPerMin()), friendlyNumber(last:expGained()), friendlyTime(last:getAdjustedGameTime())) + printf(" %s until level %d at this rate", friendlyTime(last:gameTimeToNextLevel()), state.level+1) + end + + if total.runs > 0 then + printf("\nAverage run:") + printf(" %s xp/min (%s xp in %s)", friendlyNumber(total:averageExpPerMinPerRun()), friendlyNumber(total:averageExpPerRun()), friendlyTime(total:averageGameTimePerRun())) + local runsNeeded = total:runsToNextLevel() + printf(" %s runs until level %d", runsNeeded and string.format("%d", runsNeeded) or "-", state.level+1) + end + + if total then + printf("\nThis session:") + local percentGain = expToPercentLeveled(state.exp, state.level) - expToPercentLeveled(total.startExp, state.level) + printf(" %s%.1f ticks (%s%.2f%%)", total:ticksGained() >= 0 and "+" or "", total:ticksGained(), percentGain >= 0 and "+" or "", percentGain*100) + printf(" %s xp/min (%s xp in %s)", friendlyNumber(total:durationExpPerMin()), friendlyNumber(total:expGained()), friendlyTime(total.runsTotalDuration + current:getAdjustedGameTime())) + printf(" %s until level %d at this rate", friendlyTime(total:gameTimeToNextLevel()), state.level+1) + end + + --[[ + printf("Real-time:") + printf(" %s xp/min (in %s)", friendlyNumber(total:realTimeExpPerMin()), friendlyTime(os.time() - total.startTime)) + printf(" %s until level %d in real-time", friendlyTime(total:gameTimeToNextLevel()), state.level+1) + ]]-- + + if state.config:get("SHOW_AREA_INFORMATION") then + if state.area and state.difficulty and not state.area.town then + local alvl = state.area.alvl[state.difficulty.code] + printf("\n%s [%s] alvl=%d", state.area.name, state.difficulty.name, alvl) + printf("Experience gain at level %d: %0.4f%%", state.level, utils.expLevelPenalty(state.level)*100) + printf("Monsters (lvl%d): \t%0.4f%% exp", alvl, utils.expGain(alvl, state.level)*100) + printf("Champions (lvl%d): \t%0.4f%% exp", alvl+2, utils.expGain(alvl+2, state.level)*100) + printf("Uniques (lvl%d): \t%0.4f%% exp", alvl+3, utils.expGain(alvl+3, state.level)*100) + end + end + else + print(state.reader.status or "No player") + end end -function Output:toFile(player, level, total, current, last) +function Output:toFile(state) + local sessions = state:getSessions() + if not sessions then return end + local total, current, last = sessions.total, sessions.current, sessions.last toFile(self.outputDir .. "/xpmin-realtime.txt", friendlyNumber(total:realTimeExpPerMin())) toFile(self.outputDir .. "/xpmin-gametime.txt", friendlyNumber(total:durationExpPerMin())) toFile(self.outputDir .. "/xpmin-currentgame.txt", current and friendlyNumber(current:realTimeExpPerMin()) or "-") @@ -46,6 +90,18 @@ function Output:toFile(player, level, total, current, last) toFile(self.outputDir .. "/ticksgained-overall.txt", string.format("%0.1f", total:ticksGained())) toFile(self.outputDir .. "/ticksgained-currentgame.txt", current and string.format("%0.1f", current:ticksGained()) or "-") toFile(self.outputDir .. "/ticksgained-lastgame.txt", last and string.format("%0.1f", last:ticksGained()) or "-") + toFile(self.outputDir .. "/run-number.txt", total.runs+1) + toFile(self.outputDir .. "/runs-average-xpmin.txt", total.runs > 0 and friendlyNumber(total:averageExpPerMinPerRun()) or "-") + toFile(self.outputDir .. "/runs-average-duration.txt", total.runs > 0 and friendlyTime(total:averageGameTimePerRun()) or "-") + toFile(self.outputDir .. "/runs-average-xpgain.txt", total.runs > 0 and friendlyNumber(total:averageExpPerRun()) or "-") + local runsNeeded = total:runsToNextLevel() + toFile(self.outputDir .. "/runs-average-timetolevel.txt", runsNeeded and string.format("%d", runsNeeded) or "-") + + if state.ingame then + toFile(self.outputDir .. "/level.txt", state.level) + toFile(self.outputDir .. "/level-next.txt", state.level+1) + toFile(self.outputDir .. "/players-x.txt", state.playersX) + end end return Output diff --git a/d2info/session.lua b/d2info/session.lua index 8a2eec7..38be024 100644 --- a/d2info/session.lua +++ b/d2info/session.lua @@ -4,33 +4,40 @@ local utils = require('d2info.utils') local Session = {} Session.__index = Session -function Session.new(startExp, startLevel) +function Session.new(startExp, startLevel, maxDurationPerTown) local self = setmetatable({}, Session) self.startTime = os.time() self.immutableStartTime = self.startTime self.duration = 0 + self.townDurations = {} + self.maxDurationPerTown = maxDurationPerTown self.startExp = startExp self.exp = startExp self.startLevel = startLevel self.level = startLevel + self.runs = 0 + self.runsTotalDuration = 0 return self end -function Session:update(exp, level) +function Session:update(dt, state) + -- we can safely skip updating anything during a pause + if state:isPaused() then return end + + if state:inTown() then + self.townDurations[state.area.act] = (self.townDurations[state.area.act] or 0) + dt + end + self.duration = self.duration + dt -- reset the session on level up, because the amount of -- exp gained changes depending on your level - if level > self.level then + if state.level > self.level then self.startTime = os.time() self.duration = 0 - self.startExp = exp + self.townDurations = {} + self.startExp = state.exp end - self.exp = exp - self.level = level -end - -function Session:incrementDuration(inc) - if inc == nil then inc = 1 end - self.duration = self.duration + inc + self.exp = state.exp + self.level = state.level end function Session:expGained() @@ -42,6 +49,17 @@ function Session:ticksGained() return utils.expToTicks(gainedIntoLevel, self.level) end +function Session:getAdjustedGameTime() + if not self.maxDurationPerTown then return self.duration end + local adjustment = 0 + for act=1,5 do + if self.townDurations[act] and self.townDurations[act] > self.maxDurationPerTown then + adjustment = adjustment + (self.townDurations[act] - self.maxDurationPerTown) + end + end + return self.duration - adjustment +end + local function expPerMin(expGained, duration) local mins = duration / 60 if mins == 0 then return 0 end @@ -53,7 +71,7 @@ function Session:realTimeExpPerMin() end function Session:durationExpPerMin() - return expPerMin(self:expGained(), self.duration) + return expPerMin(self:expGained(), self:getAdjustedGameTime()) end local function secondsToNextLevel(exp, level, expGained, duration) @@ -69,7 +87,33 @@ function Session:realTimeToNextLevel() end function Session:gameTimeToNextLevel() - return secondsToNextLevel(self.exp, self.level, self:expGained(), self.duration) + return secondsToNextLevel(self.exp, self.level, self:expGained(), self:getAdjustedGameTime(self.maxDurationPerTown)) +end + +function Session:onGameEnd(endedSession) + self.runs = self.runs + 1 + self.expPerRun = self:expGained() / self.runs + self.runsTotalDuration = self.runsTotalDuration + endedSession:getAdjustedGameTime() +end + +function Session:averageExpPerRun() + return self.expPerRun +end + +function Session:averageExpPerMinPerRun() + return self:averageExpPerRun() / (self:averageGameTimePerRun() / 60) +end + +function Session:averageGameTimePerRun() + if self.runs == 0 then return nil end + return self.runsTotalDuration / self.runs +end + +function Session:runsToNextLevel() + if self.runs == 0 or self.expPerRun == 0 then return nil end + local expNeeded = constants.experience[self.level+1] - self.exp + local runsNeeded = expNeeded / self.expPerRun + return runsNeeded end return Session diff --git a/d2info/utils.lua b/d2info/utils.lua index 3923a29..dcdd0a1 100644 --- a/d2info/utils.lua +++ b/d2info/utils.lua @@ -58,14 +58,59 @@ function utils.toFile(filename, txt) f:close() end +function utils.expToPercentLeveled(exp, level) + if level == 99 then return 0 end + local expRange = constants.experience[level+1] - constants.experience[level] + local expGotten = exp - constants.experience[level] + return expGotten / expRange +end + -- Converts exp to GUI 'ticks' of the experience bar function utils.expToTicks(exp, level) if level == 99 then return 0 end local maxTicks = constants.gui.expBar.ticks - local expRange = constants.experience[level+1] - constants.experience[level] - local expGotten = exp - constants.experience[level] - local percentLeveled = expGotten / expRange + local percentLeveled = utils.expToPercentLeveled(exp, level) return percentLeveled * maxTicks end +local underLevel25 = { + [10] = 0.02, [9] = 0.15, [8] = 0.36, [7] = 0.68, [6] = 0.88, [-6] = 0.81, [-7] = 0.62, [-8] = 0.43, [-9] = 0.24, [-10] = 0.05 +} +local level25Plus = { + [-6] = 0.81, [-7] = 0.62, [-8] = 0.43, [-9] = 0.24, [-10] = 0.05 +} +function utils.expLevelDifference(mlvl, clvl) + local diff = mlvl-clvl + if clvl < 25 then + if underLevel25[diff] then + return underLevel25[diff] + elseif diff > 10 then + return 0.02 + elseif diff < -10 then + return 0.05 + else + return 1.0 + end + else + -- For any monster above your level, you get EXP*(Player Level / Monster Level). + if diff > 0 then + return clvl / mlvl + elseif level25Plus[diff] then + return level25Plus[diff] + elseif diff <= -10 then + return 0.05 + else + return 1.0 + end + end +end + +function utils.expLevelPenalty(clvl) + return constants.experienceLevelPenalties[clvl] or 1 +end + +function utils.expGain(mlvl, clvl) + return utils.expLevelDifference(mlvl, clvl) * utils.expLevelPenalty(clvl) +end + return utils diff --git a/scripts/build-luastatic.sh b/scripts/build-luastatic.sh index 0a920f5..373b2d0 100755 --- a/scripts/build-luastatic.sh +++ b/scripts/build-luastatic.sh @@ -17,6 +17,7 @@ LUA_VERSION=5.1.5 LFS_VERSION=1.7.0-2 SLEEP_VERSION=1.0.0-2 MEMREADER_VERSION=1.0.0-1 +BITOP_VERSION=1.0.2-3 # ensure luastatic and luarocks are available which luastatic || { echo "luastatic not found"; exit 1; } @@ -42,6 +43,11 @@ echo "=== Downloading LuaFileSystem $LFS_VERSION ===" echo luarocks unpack luafilesystem $LFS_VERSION +echo +echo "=== Downloading LuaBitOp $BITOP_VERSION ===" +echo +luarocks unpack luabitop $BITOP_VERSION + echo echo "=== Building Lua ===" echo @@ -77,6 +83,15 @@ x86_64-w64-mingw32-ar rcs src/lfs.a src/lfs.o cp src/lfs.a $BUILD_DIR cd $BUILD_DIR +echo +echo "=== Building LuaBitOp $BITOP_VERSION ===" +echo +cd luabitop-$BITOP_VERSION/luabitop +x86_64-w64-mingw32-gcc -c -O2 bit.c -I$BUILD_DIR/lua-$LUA_VERSION/src -o bit.o +x86_64-w64-mingw32-ar rcs bit.a bit.o +cp bit.a $BUILD_DIR +cd $BUILD_DIR + echo echo "=== Copying d2info sources ===" echo @@ -86,7 +101,7 @@ cp $ROOT_DIR/d2info.lua $BUILD_DIR echo echo "=== Building d2info.exe ===" echo -CC=x86_64-w64-mingw32-gcc luastatic d2info.lua d2info/*.lua liblua.a libmemreader.a libsleep.a lfs.a /usr/x86_64-w64-mingw32/lib/libversion.a /usr/x86_64-w64-mingw32/lib/libpsapi.a -Ilua-$LUA_VERSION/src +CC=x86_64-w64-mingw32-gcc luastatic d2info.lua d2info/*.lua liblua.a libmemreader.a libsleep.a lfs.a bit.a /usr/x86_64-w64-mingw32/lib/libversion.a /usr/x86_64-w64-mingw32/lib/libpsapi.a -Ilua-$LUA_VERSION/src strip d2info.exe cd $ROOT_DIR