diff --git a/cosmic rage/alien/core.dll b/cosmic rage/alien/core.dll new file mode 100644 index 0000000..b7da37f Binary files /dev/null and b/cosmic rage/alien/core.dll differ diff --git a/cosmic rage/alien/struct.dll b/cosmic rage/alien/struct.dll new file mode 100644 index 0000000..26e9c0b Binary files /dev/null and b/cosmic rage/alien/struct.dll differ diff --git a/cosmic rage/lua/alien.lua b/cosmic rage/lua/alien.lua new file mode 100644 index 0000000..71c08b7 --- /dev/null +++ b/cosmic rage/lua/alien.lua @@ -0,0 +1,245 @@ + + +local core = require "alien.core" +local io = require "io" + +local pairs, ipairs = pairs, ipairs +local setmetatable = setmetatable +local error = error +local pcall = pcall +local type = type +local rawset = rawset +local unpack = unpack +local math = math +local print = print + +module "alien" + +loaded = {} + +local load_library, find_library = {}, {} + +local function find_library_helper(libname, opt) + local expr = '/[^() ]*lib' .. libname .. '\\.so[^() ]*' + local cmd = '/sbin/ldconfig ' .. opt .. + ' 2>/dev/null | egrep -o "' .. expr .. '"' + local pipe = io.popen(cmd) + if pipe then + local res = pipe:read() + pipe:close() + return res and res:match("([^%s]*)") + end + return nil +end + +function find_library.linux(libname) + return find_library_helper(libname, "-p") +end + +function find_library.bsd(libname) + return find_library_helper(libname, "-r") +end + +function find_library.darwin(libname) + local ok, lib = pcall(core.load, libname .. ".dylib") + if ok then return lib end + ok, lib = pcall(core.load, libname .. ".framework/" .. libname) + if ok then return lib end + return nil +end + +local function load_library_helper(libname, libext) + if libname:match("/") or libname:match("%" .. libext) then + return core.load(libname) + else + local ok, lib = pcall(core.load, "lib" .. libname .. libext) + if not ok then + ok, lib = pcall(core.load, "./lib" .. libname .. libext) + if not ok then + local name = find_library[core.platform](libname) + if name then + lib = core.load(name) + else + error("library " .. libname .. " not found") + end + end + end + return lib + end +end + +function load_library.linux(libname) + return load_library_helper(libname, ".so") +end + +load_library.bsd = load_library.linux + +function load_library.darwin(libname) + return load_library_helper(libname, ".dylib") +end + +setmetatable(load_library, { __index = function (t, plat) + return core.load + end } ) + +function load_library.windows(libname) + return core.load(libname) +end + +setmetatable(loaded, { __index = function (t, libname) + local lib = + load_library[core.platform](libname) + t[libname] = lib + return lib + end }) + +setmetatable(_M, { __index = loaded }) + +for name, f in pairs(core) do + _M[name] = f +end + +function load(libname) + return loaded[libname] +end + +function callback(f, ...) + local cb = core.callback(f) + cb.types(cb, ...) + return cb +end + +local array_methods = {} + +local function array_next(arr, i) + if i < arr.length then + return i + 1, arr[i + 1] + else + return nil + end +end + +function array_methods:ipairs() + return array_next, self, 0 +end + +local function array_get(arr, key) + if type(key) == "number" then + if key < 1 or key > arr.length then + error("array access out of bounds") + end + local offset = (key - 1) * arr.size + 1 + return arr.buffer:get(offset, arr.type) + else + return array_methods[key] + end +end + +local function array_set(arr, key, val) + if type(key) == "number" then + if key < 1 or key > arr.length then + error("array access out of bounds") + end + local offset = (key - 1) * arr.size + 1 + arr.buffer:set(offset, val, arr.type) + if type(val) == "string" or type(val) == "userdata" then + arr.pinned[key] = val + end + else + rawset(arr, key, val) + end +end + +function array(t, length, init) + local ok, size = pcall(core.sizeof, t) + if not ok then + error("type " .. t .. " does not exist") + end + if type(length) == "table" then + init = length + length = #length + end + local arr = { type = t, length = length, size = size, pinned = {} } + setmetatable(arr, { __index = array_get, __newindex = array_set }) + if type(init) == "userdata" then + arr.buffer = init + else + arr.buffer = core.buffer(size * length) + if type(init) == "table" then + for i = 1, length do + arr[i] = init[i] + end + end + end + return arr +end + +local function struct_new(s_proto, ptr) + local buf = core.buffer(ptr or s_proto.size) + local function struct_get(_, key) + if s_proto.offsets[key] then + return buf:get(s_proto.offsets[key] + 1, s_proto.types[key]) + else + error("field " .. key .. " does not exist") + end + end + local function struct_set(_, key, val) + if s_proto.offsets[key] then + buf:set(s_proto.offsets[key] + 1, val, s_proto.types[key]) + else + error("field " .. key .. " does not exist") + end + end + return setmetatable({}, { __index = struct_get, __newindex = struct_set, + __call = function () return buf end }) +end + +local function struct_byval(s_proto) + local types = {} + local size = s_proto.size + for i = 0, size - 1, 4 do + if size - i == 1 then + types[#types + 1] = "char" + elseif size - i == 2 then + types[#types + 1] = "short" + else + types[#types + 1] = "int" + end + end + return unpack(types) +end + +function defstruct(t) + local off = 0 + local names, offsets, types = {}, {}, {} + for _, field in ipairs(t) do + local name, type = field[1], field[2] + names[#names + 1] = name + off = math.ceil(off / core.align(type)) * core.align(type) + offsets[name] = off + types[name] = type + off = off + core.sizeof(type) + end + return { names = names, offsets = offsets, types = types, size = off, new = struct_new, + byval = struct_byval } +end + +function byval(buf) + if buf.size then + local size = buf.size + local types = { "char", "short"} + local vals = {} + for i = 1, size, 4 do + if size - i == 0 then + vals[#vals + 1] = buf:get(i, "char") + elseif size - i == 1 then + vals[#vals + 1] = buf:get(i, "short") + else + vals[#vals + 1] = buf:get(i, "int") + end + end + return unpack(vals) + else + error("this type of buffer can't be passed by value") + end +end diff --git a/cosmic rage/worlds/Cosmic Rage/cosmic rage.MCL b/cosmic rage/worlds/Cosmic Rage/cosmic rage.MCL index b213029..80725e9 100644 --- a/cosmic rage/worlds/Cosmic Rage/cosmic rage.MCL +++ b/cosmic rage/worlds/Cosmic Rage/cosmic rage.MCL @@ -1,6 +1,6 @@ - + @@ -8,13 +8,13 @@ @@ -140,7 +140,7 @@ @@ -149,7 +149,7 @@ @@ -243,7 +243,7 @@ @@ -252,7 +252,7 @@ @@ -309,7 +309,7 @@ @@ -423,7 +423,7 @@ @@ -448,7 +448,6 @@ - @@ -457,5 +456,7 @@ + + diff --git a/cosmic rage/worlds/plugins/CosmicRage.xml b/cosmic rage/worlds/plugins/CosmicRage.xml index 884c932..d2bd085 100644 --- a/cosmic rage/worlds/plugins/CosmicRage.xml +++ b/cosmic rage/worlds/plugins/CosmicRage.xml @@ -25,7 +25,7 @@ sequence="10000" for i = 1, 9 do Accelerator("alt + " .. i, "@buffer " .. i) end -Accelerator("alt + 0", "@buffer 10") -- 10 is a special case +Accelerator("alt + 0", "@buffer 10") Accelerator("alt + right", "@buffer forward") Accelerator("alt + left", "@buffer backward") Accelerator("alt + down", "@buffer down") @@ -34,41 +34,64 @@ Accelerator("alt + pagedown", "@buffer scrolldown") Accelerator("alt + pageup", "@buffer scrollup") Accelerator("alt + end", "@buffer bottom") Accelerator("alt + home", "@buffer top") -Accelerator("alt+shift+delete", "@buffer clear") +Accelerator("alt+delete", "@buffer clear") +Accelerator("alt+shift+delete", "@buffer clearall") Accelerator("alt+shift+right", "@buffer add") Accelerator("alt+shift+left", "@buffer delete") Accelerator("f4", "@sp-settings master volume") - Accelerator("f5", "@sp-settings General volume") - Accelerator("f6", "@sp-settings ambiance volume") - Accelerator("f7", "@sp-settings in and out of character communications volume") - Accelerator("f8", "@sp-settings planetary music volume") - Accelerator("ctrl+f5", "@sp-settings clocks volume") - Accelerator("ctrl+f6", "@sp-settings Combat music volume") - Accelerator("ctrl+f7", "@sp-settings IC social volume") - Accelerator("ctrl+f8", "@sp-settings OOC social volume") - Accelerator("alt+f5", "@sp-settings Ground Combat volume") - Accelerator("alt+f6", "@sp-settings harvesting activity volume") - Accelerator("alt+f7", "@sp-settings Salvage activity volume") - Accelerator("alt+f8", "@sp-settings volume for games such as Pilot's Parody") - Accelerator("shift+f5", "@sp-settings starship combat volume") - Accelerator("shift+f6", "@sp-settings ship volume") - Accelerator("shift+f7", "@sp-settings ship movement volume") - Accelerator("shift+f8", "@sp-settings vehicle volume") - Accelerator("f9", "@sp-settings IC social sounds") - Accelerator("f10", "@sp-settings OOC social sounds") - Accelerator("alt+f9", "@sp-settings panning of sounds") - Accelerator("alt+f10", "@sp-settings pitch-shifting sounds") - Accelerator("alt+f11", "@sp-settings ambiance") - Accelerator("ctrl+f9", "@sp-settings planetary music") - Accelerator("ctrl+f10", "@sp-settings space combat music") - Accelerator("ctrl+f11", "@sp-settings weather sounds") - Accelerator("alt+f12", "@sp-restart") - Accelerator("shift+alt+f9", "@sp-settings Interrupt OOC Social sounds when the next one plays") - Accelerator("f2", "@sp-settings") - +Accelerator("f5", "@sp-settings General volume") +Accelerator("f6", "@sp-settings ambiance volume") +Accelerator("f7", "@sp-settings in and out of character communications volume") +Accelerator("f8", "@sp-settings planetary music volume") +Accelerator("ctrl+f5", "@sp-settings clocks volume") +Accelerator("ctrl+f6", "@sp-settings Combat music volume") +Accelerator("ctrl+f7", "@sp-settings IC social volume") +Accelerator("ctrl+f8", "@sp-settings OOC social volume") +Accelerator("alt+f5", "@sp-settings Ground Combat volume") +Accelerator("alt+f6", "@sp-settings harvesting activity volume") +Accelerator("alt+f7", "@sp-settings Salvage activity volume") +Accelerator("alt+f8", "@sp-settings volume for games such as Pilot's Parody") +Accelerator("shift+f5", "@sp-settings starship combat volume") +Accelerator("shift+f6", "@sp-settings ship volume") +Accelerator("shift+f7", "@sp-settings ship movement volume") +Accelerator("shift+f8", "@sp-settings vehicle volume") +Accelerator("f9", "@sp-settings IC social sounds") +Accelerator("f10", "@sp-settings OOC social sounds") +Accelerator("alt+f9", "@sp-settings panning of sounds") +Accelerator("alt+f10", "@sp-settings pitch-shifting sounds") +Accelerator("alt+f11", "@sp-settings ambiance") +Accelerator("ctrl+f9", "@sp-settings planetary music") +Accelerator("ctrl+f10", "@sp-settings space combat music") +Accelerator("ctrl+f11", "@sp-settings weather sounds") +Accelerator("shift+alt+f9", "@sp-settings Interrupt OOC Social sounds when the next one plays") +Accelerator("f2", "@sp-settings") Accelerator("shift+f1", "@sp-toggle") Accelerator("Ctrl+shift+enter", "@sp-settings buffer sounds") Accelerator("ctrl+shift+v", "@sp-version") +Accelerator("ctrl+s", "save_state_func") +Accelerator("alt+shift+h", "localsphelp") +Accelerator("ctrl+f2", "mainvoldown") +Accelerator("ctrl+f3", "mainvolup") +Accelerator("alt+f3", "intromusictoggle") +Accelerator("ctrl+f1", "smute") +Accelerator("f11", "f11_stop") +Accelerator("alt+f12", "alt_f12_stop") + +function save_state_func() + SaveState() + Execute("tts_interrupt Client-side Soundpack Settings have been saved successfully!") +end + +function f11_stop() + ppi.stop(0) + Execute("tts_interrupt All currently playing sounds have been stopped.") + Send("@sp-restart") +end + +function alt_f12_stop() + ppi.stop(0) + Execute("tts_interrupt All currently playing sounds have been stopped.") +end function RegisterMushClient(name, line, wildcards) @@ -91,7 +114,15 @@ Note("You are currently running version " .. tostring(CURRENT_SP_VERSION).." of if version_diff > 0 then ColourNote("yellow", "", "Warning: Your soundpack is " .. version_diff .. " version(s) out of date. We are now on version " .. user_version .. " of the soundpack.") - ColourNote("yellow", "", "Run Updater.bat to pull the latest version.") + ColourNote("yellow", "", "Launching the updater...") + ppi.play(dir.."sounds/ogg/general/comms/announcement.ogg", 0, 0, 100) + local bat_path = dir .. "..\\updator.bat" + -- Signature is (filename, params, defdir, operation, show) - this was + -- previously passing "open" as the filename, so it never actually + -- launched cmd.exe/updator.bat at all. + utils.shellexecute("cmd", '/c "' .. bat_path .. '"', "", "open", 1) + ColourNote("yellow", "", "The updater has been launched in a separate window. Once the update is complete, please restart MUSHclient to apply the updates!") + Execute("tts_interrupt Warning: Your soundpack is out of date. The updater has been launched. Once the update is complete, please restart MUSHclient to apply the updates!") else ColourNote("green", "", "Your soundpack is up to date!") end @@ -102,6 +133,109 @@ function url_encode(str) return string.format("%%%02X", string.byte(c)) end)) end + +pending_downloads = {} +pending_play = {} + +function download_sound(path, action, pan, volume, pitch, id) + if pending_downloads[path] then + table.insert(pending_play[path], {action=action, pan=pan, volume=volume, pitch=pitch, id=id}) + return + end + + pending_downloads[path] = os.time() + pending_play[path] = { {action=action, pan=pan, volume=volume, pitch=pitch, id=id} } + + local sound_path = dir .. "sounds/ogg/" .. path .. ".ogg" + local safe_path = sound_path:gsub("/", "\\") + local parent_dir = safe_path:match("^(.+)\\[^\\]+$") + local encoded_path = url_encode(path) + local url = "http://nathantech.net:3000/CosmicRage/CosmicRageSounds/raw/branch/main/ogg/" .. encoded_path .. ".ogg" + + -- Download to a .part file and only rename it to the real filename after + -- curl succeeds, so check_downloads() can never mistake a half-written or + -- failed transfer for a completed one. Clears any leftover .err first, and + -- deletes it again on success, so a stale error/empty file can't linger. + local cmd_args = string.format( + '/C mkdir "%s" 2>nul & del /q "%s.err" 2>nul & ' .. + 'curl.exe -s -S -L -f -o "%s.part" "%s" 2>"%s.err" ' .. + '&& (move /y "%s.part" "%s" >nul & del /q "%s.err" 2>nul) || del /q "%s.part" 2>nul', + parent_dir, safe_path, safe_path, url, safe_path, safe_path, safe_path, safe_path, safe_path + ) + -- NOTE: correct shellexecute signature is (filename, params, defdir, operation, show) + utils.shellexecute("cmd", cmd_args, "", "open", 0) +end + +-- If a download hasn't finished or failed within this long, give up on it +-- rather than let a stuck curl process silently block that sound forever. +DOWNLOAD_TIMEOUT_SECONDS = 30 + +function check_downloads(name) + for path, started_at in pairs(pending_downloads) do + local sound_path = dir .. "sounds/ogg/" .. path .. ".ogg" + local safe_path = sound_path:gsub("/", "\\") + local f = io.open(sound_path, "r") + if f then + f:close() + local queue = pending_play[path] + pending_downloads[path] = nil + pending_play[path] = nil + if queue then + for _, p in ipairs(queue) do + play_sound_now(path, p.action, p.pan, p.volume, p.pitch, p.id) + end + end + else + local ef = io.open(safe_path .. ".err", "r") + if ef then + local msg = ef:read("*all") + ef:close() + if msg and msg ~= "" then + os.remove(safe_path .. ".err") + pending_downloads[path] = nil + pending_play[path] = nil + Note("Soundpack: download failed for " .. path .. ": " .. msg:gsub("[\r\n]", "")) + end + elseif os.time() - started_at > DOWNLOAD_TIMEOUT_SECONDS then + pending_downloads[path] = nil + pending_play[path] = nil + Note("Soundpack: download timed out for " .. path) + end + end + end +end + +function play_sound_now(path, action, pan, volume, pitch, id) + local sound_file = dir.."sounds/ogg/"..path..".ogg" + local temp + if (action == "play") then + temp = ppi.play(sound_file, 0, pan, volume) + if (pitch > -10000) then + ppi.setPitch(pitch, temp) + end + if (pan > -10000) then + ppi.setPan(pan, temp) + end + elseif (action == "playrec") then + temp = ppi.play(sound_file, 0, pan, volume) + SetVariable(id, temp) + if (pitch > -10000) then + ppi.setPitch(pitch, temp) + end + if (pan > -10000) then + ppi.setPan(pan, temp) + end + elseif (action == "loop") then + temp = ppi.play(sound_file, 1, pan, volume) + SetVariable(id, temp) + if (pitch > -10000) then + ppi.setPitch(pitch, temp) + end + if (pan > -10000) then + ppi.setPan(pan, temp) + end + end +end ]]> @@ -197,106 +331,87 @@ match="^\$sphook\s+(?P[\w]+):(?P[-\w./\\\s]+):(?P[\w]+):(? pan="%5" id="%6" volume=tonumber(volume) -if (path == "na") then -else - sound_path = dir .. "sounds/ogg/" .. path .. ".ogg" - directory_path = sound_path:match("(.*/)") -if directory_path then - test_file = io.open(directory_path .. "tester.txt", "w") - if test_file then - test_file:close() - os.remove(directory_path .. "checker.txt") -- Clean up + + if (pitch == "na") then + pitch = -10000 else - os.execute('mkdir "' .. directory_path .. '" 2>nul') + pitch = tonumber(pitch) + pitch = (pitch - 44100) / 98 end -end - file = io.open(sound_path, "r") -if file then - file:close() -else -local encoded_path = url_encode(path) -local download_url = "http://nathantech.net:3000/CosmicRage/CosmicRageSounds/raw/branch/main/ogg/" .. encoded_path .. ".ogg" - body = {} - result, status_code, headers, status = socket.http.request{ - url = download_url, sink = ltn12.sink.table(body) - } - if result and status_code == 200 then - save_file = io.open(sound_path, "wb") - if save_file then - save_file:write(table.concat(body)) - save_file:close() + + if (pan == "na") then + pan = -10000 + else + pan = tonumber(pan) + end + + if (path == "na") then + if (action == "adjustsound") then + temp = GetVariable(id) + if volume and volume > -1 then + ppi.setVol(volume, temp) + end + if (pitch > -10000) then + ppi.setPitch(pitch, temp) + end + if (pan > -10000) then + ppi.setPan(pan, temp) + end + elseif (action == "stop") then + temp = GetVariable(id) + if temp then + ppi.stop(temp) + end + end + else + local sound_path = dir .. "sounds/ogg/" .. path .. ".ogg" + local file = io.open(sound_path, "r") + if file then + file:close() + play_sound_now(path, action, pan, volume, pitch, id) + else + download_sound(path, action, pan, volume, pitch, id) end -else - Note("Download of "..path.." failed with status code: " .. tostring(status_code)) - end -end -end - -if (pitch == "na") then - pitch = -10000 -else - pitch = tonumber(pitch) - pitch = (pitch - 44100) / 98 -end - -if (pan == "na") then - pan = -10000 -else - pan = tonumber(pan) -end - -if (action == "play") then - temp = ppi.play(dir.."sounds/ogg/"..path..".ogg", 0, pan, volume) - if (pitch > -10000) then - ppi.setPitch(pitch, temp) - end - if (pan > -10000) then - ppi.setPan(pan, temp) - end - end - if (action=="playrec") then - temp=ppi.play(dir.."sounds/ogg/"..path..".ogg", 0, pan, volume) - SetVariable(id, temp) - if(pitch>-10000) then - ppi.setPitch(pitch,temp) - end - if(pan>-10000) then - ppi.setPan(pan,temp) - end - end - if (action=="loop") then - temp=ppi.play(dir.."sounds/ogg/"..path..".ogg", 1, pan, volume) - SetVariable(id, temp) - if(pitch>-10000) then - ppi.setPitch(pitch,temp) - end - if(pan>-10000) then - ppi.setPan(pan,temp) - end - end - if (action=="adjustsound") then - temp=GetVariable (id) -if(volume>-1) then - ppi.setVol(volume,temp) - end - if(pitch>-10000) then - ppi.setPitch(pitch,temp) - end - if(pan>-10000) then - ppi.setPan(pan,temp) - end - end - if (action=="stop") then - temp=GetVariable(id) - ppi.stop(temp) end - + + + + + + + + + + + \ No newline at end of file diff --git a/cosmic rage/worlds/plugins/MushReader.xml b/cosmic rage/worlds/plugins/MushReader.xml index 387798d..fe8939f 100644 --- a/cosmic rage/worlds/plugins/MushReader.xml +++ b/cosmic rage/worlds/plugins/MushReader.xml @@ -298,6 +298,10 @@ function OnPluginScreendraw(t, log, line) -- checking for an empty string, or a string composed only of spaces. -- If we don't, NVDA says blank. if (t == 0 or t == 1) and not line:find("^%s*$") then + if GetVariable("skip_speech") == "1" then + SetVariable("skip_speech", "0") + return + end if subst.status==0 then return end diff --git a/cosmic rage/worlds/plugins/output_functions.xml b/cosmic rage/worlds/plugins/output_functions.xml index 57795f4..e71e33c 100644 --- a/cosmic rage/worlds/plugins/output_functions.xml +++ b/cosmic rage/worlds/plugins/output_functions.xml @@ -14,8 +14,8 @@ save_state="y" language="Lua" purpose="provides functions to help tts users." date_written="2010-04-06 08:37:40" - requires="4.46" - version="1.0" + requires="4.82" + version="1.1" > @@ -172,6 +172,17 @@ sequence="100"> + + + + + + + @@ -200,6 +211,7 @@ Accelerator("ctrl+shift+n","endline") Accelerator("ctrl+shift+y","topline") Accelerator("ctrl+shift+h","whichline") Accelerator("ctrl+shift+alt+s","snap_shot") +Accelerator("ctrl+alt+j","toggle_jump_to_end") function selectscr(eol) buffercheck() @@ -280,12 +292,188 @@ function OnPluginCommandEntered(s) end end +------------------------------------------------------------------ +-- Notepad smart-scroll (formerly the separate NotepadSmartScroll +-- plugin, merged in here). See SmartAppendToNotepad's comment below +-- for why this needs raw Win32 access via Alien. +------------------------------------------------------------------ + +require "alien" + +local user32 = alien.load("user32.dll") +local kernel32 = alien.load("kernel32.dll") + +local FindWindowEx = user32.FindWindowExA +FindWindowEx:types{ ret = "pointer", "pointer", "pointer", "string", "string", abi = "stdcall" } + +local GetWindowThreadProcessId = user32.GetWindowThreadProcessId +GetWindowThreadProcessId:types{ ret = "uint", "pointer", "ref uint", abi = "stdcall" } + +local GetCurrentProcessId = kernel32.GetCurrentProcessId +GetCurrentProcessId:types{ ret = "uint", abi = "stdcall" } + +local SendMessage = user32.SendMessageA +SendMessage:types{ ret = "long", "pointer", "uint", "uint", "long", abi = "stdcall" } + +local GetModuleHandle = kernel32.GetModuleHandleA +GetModuleHandle:types{ ret = "pointer", "string", abi = "stdcall" } + +local GetProcAddress = kernel32.GetProcAddress +GetProcAddress:types{ ret = "pointer", "pointer", "string", abi = "stdcall" } + +local GetFocus = user32.GetFocus +GetFocus:types{ ret = "pointer", abi = "stdcall" } + +-- EM_GETSEL's wParam/lParam are *pointers* to DWORD (needed to get the +-- real 32-bit selection range - the classic packed-return form is capped +-- at 64K). SendMessage above is already typed with "uint"/"long" for its +-- generic wParam/lParam, so a second binding of the same underlying +-- function is needed with "pointer" typed args for this one message. +local user32_handle = GetModuleHandle("user32.dll") +local SendMessagePtr = alien.funcptr(GetProcAddress(user32_handle, "SendMessageA")) +SendMessagePtr:types{ ret = "long", "pointer", "uint", "pointer", "pointer", abi = "stdcall" } + +local WM_GETTEXTLENGTH = 0x000E +local EM_GETFIRSTVISIBLELINE = 0x00CE +local EM_LINESCROLL = 0x00B6 +local EM_GETSEL = 0x00B0 +local EM_SETSEL = 0x00B1 +local EM_SCROLLCARET = 0x00B7 + +local my_pid = GetCurrentProcessId() + +-- Finds the "Edit" control belonging to a MUSHclient notepad MDI child +-- window whose title exactly matches. Scoped to top-level windows owned +-- by this process, so it never touches unrelated apps on the desktop. +local function find_notepad_edit(title) + local top = nil + while true do + top = FindWindowEx(nil, top, nil, nil) + if not top then return nil end + + local _, pid = GetWindowThreadProcessId(top, 0) + if pid == my_pid then + local mdiClient = FindWindowEx(top, nil, "MDIClient", nil) + if mdiClient then + local frame = FindWindowEx(mdiClient, nil, nil, title) + if frame then + local edit = FindWindowEx(frame, nil, "Edit", nil) + if edit then + return edit + end + end + end + end + end +end + +local function get_selection(edit) + local start_buf = alien.buffer(4) + local end_buf = alien.buffer(4) + SendMessagePtr(edit, EM_GETSEL, start_buf, end_buf) + return start_buf:get(1, "uint"), end_buf:get(1, "uint") +end + +local function scroll_to_line(edit, target) + local current = SendMessage(edit, EM_GETFIRSTVISIBLELINE, 0, 0) + local delta = target - current + if delta ~= 0 then + SendMessage(edit, EM_LINESCROLL, 0, delta) + end +end + +-- Wraps AppendToNotepad(title, text): if the caret wasn't already sitting +-- at the very end of the text (i.e. the user has moved it to read +-- somewhere else), both the caret/selection and the scroll position are +-- restored after the append instead of being left jumped to the end. +-- AppendToNotepad hardcodes SetSel(len,len)+ReplaceSel on every call (see +-- scripting/methods/methods_notepad.cpp in the MUSHclient source) with no +-- scriptable override, so this reads the notepad Edit control's caret and +-- scroll position via raw Win32 calls (through the Alien FFI library, +-- running in-process on MUSHclient's own thread) before appending, and +-- restores them afterward when appropriate. +-- +-- Note: text should use "\r\n" line endings, not bare "\n" - the plain +-- Win32 Edit control that backs the notepad doesn't recognise bare "\n" +-- as a line break for scrolling/line-count purposes. +function SmartAppendToNotepad(title, text) + local edit = find_notepad_edit(title) + local was_at_end = true + local sel_start, sel_end, first_visible + + if edit then + local old_length = SendMessage(edit, WM_GETTEXTLENGTH, 0, 0) + sel_start, sel_end = get_selection(edit) + was_at_end = (sel_start == old_length) and (sel_end == old_length) + if not was_at_end then + first_visible = SendMessage(edit, EM_GETFIRSTVISIBLELINE, 0, 0) + end + end + + AppendToNotepad(title, text) + + if edit and not was_at_end then + -- Re-find in case the append above created/recreated the window. + local edit2 = find_notepad_edit(title) or edit + SendMessage(edit2, EM_SETSEL, sel_start, sel_end) + scroll_to_line(edit2, first_visible) + end +end + +-- Moves the caret to the very end of a notepad and scrolls it into view. +local function jump_notepad_to_end(edit) + local len = SendMessage(edit, WM_GETTEXTLENGTH, 0, 0) + SendMessage(edit, EM_SETSEL, len, len) + SendMessage(edit, EM_SCROLLCARET, 0, 0) +end + +function toggle_jump_to_end() + if GetVariable("jump_to_end_on_focus") == "0" then + SetVariable("jump_to_end_on_focus", "1") + Execute("tts_interrupt Jump to end when switching to output: on") + else + SetVariable("jump_to_end_on_focus", "0") + Execute("tts_interrupt Jump to end when switching to output: off") + end +end + +-- Polls for the "output" notepad gaining focus (mouse click, Ctrl+Tab +-- between MDI windows, ActivateNotepad from a script, etc.), so the +-- jump-to-end behaviour applies regardless of how the user switches to +-- it. MUSHclient's own Accelerator/hotkey system can't see keystrokes +-- while a separate notepad window has focus (confirmed by Nick Gammon on +-- the MUSHclient forum), so this can't be event-driven - polling every +-- 0.2s is the safe, non-hooking way to catch it. +was_output_focused = false + +function poll_output_focus() + if GetVariable("jump_to_end_on_focus") == "0" then + was_output_focused = false + return + end + local edit = find_notepad_edit("output") + if not edit then + was_output_focused = false + return + end + local focused = GetFocus() + local is_focused = (focused == edit) + if is_focused and not was_output_focused then + jump_notepad_to_end(edit) + end + was_output_focused = is_focused +end + +------------------------------------------------------------------ + function OnPluginInstall() if GetVariable("output") == nil then SetVariable("output","1") end + if GetVariable("jump_to_end_on_focus") == nil then + SetVariable("jump_to_end_on_focus","1") + end modes = {} - msgbuffer = {} cline = 1 lastcount=0 line = 0 @@ -297,23 +485,7 @@ function OnPluginScreendraw(t,l,line) if(GetVariable("output")=="0") then return end - if GetInfo(113) then - AppendToNotepad("output", line.."\r\n") - else - table.insert(msgbuffer, line) - end -end - -function OnPluginGetFocus() - if GetVariable("output") == "0" then - return - end - if #msgbuffer > 0 then - for i, buffered in ipairs(msgbuffer) do - AppendToNotepad("output", buffered.."\r\n") - end - msgbuffer={} - end + SmartAppendToNotepad("output", line.."\r\n") end function prev_line() diff --git a/readme.md b/readme.md index fe9dbfd..b85475e 100644 --- a/readme.md +++ b/readme.md @@ -40,7 +40,8 @@ MushClient is a portable client designed specifically for Cosmic Rage. The game - **Alt + PageUp**: Scroll buffer up - **Alt + End**: Jump to bottom of buffer - **Alt + Home**: Jump to top of buffer -- **Alt + Shift + Delete**: Clear buffer +- **Alt + Delete**: Clear current buffer +- **Alt + Shift + Delete**: Clear all buffers - **Alt + Shift + Right**: Add buffer - **Alt + Shift + Left**: Delete buffer @@ -71,13 +72,39 @@ MushClient is a portable client designed specifically for Cosmic Rage. The game - **Ctrl + F9**: Toggle planetary music - **Ctrl + F10**: Toggle space combat music - **Ctrl + F11**: Toggle weather sounds -- **Alt + F12**: Restart - **Shift + Alt + F9**: Interrupt OOC social sounds on next play +- **Ctrl + F1**: Mute/unmute master sound +- **Ctrl + F2**: Master volume down +- **Ctrl + F3**: Master volume up +- **Alt + F3**: Toggle intro music +- **F11**: Stop all currently playing sounds and restart the soundpack +- **Alt + F12**: Stop all currently playing sounds ### Other Functions - **Shift + F1**: Toggle SP on/off - **Ctrl + Shift + Enter**: Toggle buffer sounds - **Ctrl + Shift + V**: Show version +- **Ctrl + S**: Save client-side soundpack settings +- **Alt + Shift + H**: Show local help + +### Accessible Output Window +The soundpack mirrors game text into a separate notepad-style window titled "output", useful for reviewing text with a screen reader without losing your place when new lines arrive. + +- **Ctrl + Alt + O**: Toggle whether output is mirrored to the output window +- **Ctrl + Alt + J**: Toggle whether switching to the output window jumps you to the newest line +- **Ctrl + Shift + C**: Clear the output buffer +- **Ctrl + Alt + Shift + C**: Clear the output window +- **Ctrl + Shift + I**: Speak the current line +- **Ctrl + Shift + U**: Speak the previous line +- **Ctrl + Shift + O**: Speak the next line +- **Ctrl + Shift + Y**: Jump to and speak the top line +- **Ctrl + Shift + N**: Jump to and speak the bottom line +- **Ctrl + Shift + H**: Announce current line number and total line count +- **Ctrl + Shift + Space**: Start/stop selecting lines (copies with line breaks) +- **Ctrl + Shift + Alt + Space**: Start/stop selecting lines (copies joined by spaces, no line breaks) +- **Ctrl + Shift + Alt + S**: Take a snapshot of the whole buffer into a "snapshot" notepad +- **Ctrl + Alt + Enter**: Toggle stopping speech whenever you press Enter +- **Ctrl + 1** to **Ctrl + 0**: Recall a recent output line by position from the end (tap once to speak it, twice to copy it, three times to paste it into the command line) ## FAQ