Radon Risk in Thunder Bay

An Assessment of Residential Radon Potential in Thunder Bay Communities Proximate to Lake Superior

Section 1: Understanding the Radon Risk: A Primer for the Thunder Bay Resident

This section provides a foundational understanding of radon, outlining the nature of the hazard, its significant health implications, and the Canadian regulatory context that guides public health actions. This information is essential for interpreting the local data specific to Thunder Bay and making informed decisions regarding home safety.

1.1 The Invisible Hazard: Defining Radon and Its Pathways

Radon is a naturally occurring radioactive gas that is imperceptible to human senses; it is colorless, odorless, and tasteless.1 It is generated through the natural, radioactive decay of uranium, an element found in trace amounts in virtually all rock and soil on Earth.3 As uranium breaks down, it forms a series of decay products, one of which is radium, which in turn decays to produce radon gas.

The primary and most significant source of radon in residential settings is the soil gas that exists in the ground beneath and around a home’s foundation.6 While certain building materials, such as granite, stone, brick, and cement, are derived from the earth and can contain uranium, they are not considered a significant source of radon in Canadian homes.8 A 2010 study by Health Canada on 33 common types of granite found that none released significant levels of radon.9 Therefore, the focus of risk assessment and mitigation must be on the interaction between a building and the underlying geology.

Because radon is a gas, it can move freely through pore spaces in soil and fractures in rock. It enters buildings through any available opening where the structure is in contact with the ground. Common entry points include 6:

  • Cracks in foundation walls and concrete floor slabs.
  • Construction joints, such as where the floor slab meets the foundation wall.
  • Gaps around service penetrations for utilities like water pipes, sewer lines, and electrical conduits.
  • Openings in the floor, such as floor drains and sump pits.
  • The hollow cores of concrete block walls.

It is a critical misconception that only older, drafty homes are at risk. Any building, regardless of age or construction style—new or old, well-sealed or drafty, with or without a basement—has the potential to accumulate high levels of radon.2 The amount of radon that enters and accumulates is a complex function of the local geology, the home’s specific construction, and the pressure dynamics between the building and the surrounding soil.

1.2 Health Implications of Long-Term Exposure

The health risks associated with radon are serious and well-documented. The International Agency for Research on Cancer (IARC), a part of the World Health Organization, classifies radon as a Group 1 carcinogen, meaning it is definitively known to cause cancer in humans.11

When radon gas is inhaled, it undergoes further radioactive decay in the lungs, releasing alpha particles. These high-energy particles can damage the DNA of the cells lining the respiratory tract.11 Over a prolonged period of exposure, this cellular damage can lead to the development of lung cancer. The overall risk to an individual is a function of three primary factors: the concentration of radon in the air, the duration of exposure, and the individual’s smoking habits.12

Radon exposure is the leading cause of lung cancer for people who have never smoked and the second leading cause overall, after smoking.11 Health Canada estimates that approximately 16% of all lung cancer deaths in the country—more than 3,000 deaths each year—are attributable to radon exposure in the home.4

The risk is dramatically amplified for individuals who smoke. The combination of radon exposure and tobacco smoke creates a synergistic effect, meaning the combined risk is much greater than the sum of the individual risks. For example, data from the Ontario Lung Association indicates that a smoker exposed to high radon levels may have a 1 in 3 chance of developing lung cancer, compared to a 1 in 10 chance for a smoker not exposed to high radon.14 This makes radon awareness and mitigation especially critical for households with smokers.

1.3 The Canadian Regulatory Framework

In Canada, the approach to radon is guided by a framework developed by Health Canada in collaboration with the Federal Provincial Territorial Radiation Protection Committee (FPTRPC).15

The Canadian guideline for radon in indoor air is an action level of 200 becquerels per cubic metre ($Bq/m^3$).12 A becquerel is a unit of radioactivity, representing one radioactive decay per second. Thus, a concentration of $200 Bq/m^3$ means that in every cubic metre of air, 200 radon atoms are decaying and emitting radiation every second. This guideline applies to the average annual concentration in the “normal occupancy area” of a building—any space where a person spends more than four hours per day, such as a finished basement, bedroom, or living room.6

It is crucial to understand that the $200 Bq/m^3$ guideline is not a demarcation of safety but rather an action level. Health Canada explicitly states that there is no level of radon that is considered risk-free.15 This position is based on the scientific consensus that any exposure to a carcinogen carries some level of risk. The guideline represents a level at which the risk is considered unacceptable from a public health perspective, and remedial action is strongly recommended. This approach is consistent with the internationally recognized ALARA (As Low As Reasonably Achievable) principle, which encourages individuals to reduce their exposure to radiation as much as is practicably possible, even if levels are already below the guideline.6 This contrasts with other jurisdictions; for example, the United States has an action level of $148 Bq/m^3$ ($4 pCi/L$), while the World Health Organization recommends a reference level between 100 and $300 Bq/m^3$.11

The urgency for taking corrective action is directly related to the measured radon concentration 6:

  • For levels between $200 Bq/m^3$ and $600 Bq/m^3$: Health Canada recommends that homeowners take corrective action within two years.12
  • For levels above $600 Bq/m^3$: Corrective action should be taken within one year.12

In the province of Ontario, radon has been integrated into the building and warranty framework. The Ontario Building Code now requires builders of new homes to incorporate radon-preventative measures, such as a soil gas barrier and a properly sealed foundation.12 Furthermore, the Tarion New Home Warranty program provides significant protection for new homeowners. Elevated radon levels exceeding the Health Canada guideline are considered a major structural defect, and Tarion may cover the costs of professional mitigation, up to a limit of $50,000, for homes within the first seven years of their warranty.12 This classification elevates the issue from a homeowner maintenance concern to a recognized construction liability, providing a powerful recourse for new buyers and underscoring the importance of proper radon-resistant construction techniques.

Section 2: The Geological Underpinnings of Radon in the Lakehead Region

The elevated radon potential in Thunder Bay is not a random phenomenon but a direct consequence of the region’s ancient and complex geology. The city’s location on the Canadian Shield, combined with specific local rock formations rich in uranium, creates a persistent and widespread source for radon gas. Understanding this geological context is fundamental to comprehending the scale and distribution of the radon risk across the city and surrounding areas.

2.1 A Legacy of the Canadian Shield: The Regional Source Rock

Thunder Bay is situated on the Superior Province of the Canadian Shield, which is the largest and one of the oldest stable blocks of the Earth’s crust, known geologically as an Archean craton.18 This geological province is renowned for its vast mineral resources, a legacy of billions of years of geological processes.18 The bedrock in the Thunder Bay area is exceptionally old, with Archean Era rocks dated to approximately 2.7 billion years.19

The local geology is a complex mosaic of rock types. The city lies at a significant geological boundary between the very old (>2.5 billion years) granite and metamorphic rocks of the Archean Shield and overlying, younger (1.8 to 1.1 billion years old) layers of sedimentary and igneous rock.20 The Archean basement consists primarily of granite, gneiss (a type of metamorphic rock), and greenstone volcanic belts.19

Granite is a key rock type in this context. As a naturally occurring igneous rock formed from the cooling of magma, granite universally contains trace amounts of naturally occurring radioactive elements, including uranium and thorium.8 The decay of these elements is the ultimate source of radon gas. While Health Canada has determined that granite used as a building material (e.g., countertops) is not a significant source of indoor radon, the vast expanse of uranium-bearing granitic and metamorphic rock underlying the region serves as a powerful, large-scale source for radon generation.9

2.2 Identifying High-Potential Formations and Uranium Occurrences

Geological surveys of the North Central Region of Ontario have identified specific formations and subprovinces in and around Thunder Bay that have a particularly high potential for uranium, and therefore, high radon production.

Two areas are explicitly identified as having the highest potential for uranium deposits: the Nipigon Basin area and the areas underlain by the Gunflint and Rove Formations.22 The Gunflint Formation, part of a larger sequence known as the Animikie Group, is a prominent geological feature in the region, stretching from Thunder Bay into the Mesabi Iron Range in Minnesota.19 It is composed of banded iron formation rocks, chert, and shale.19 Critically, this formation has been dated using Uranium-Lead (U-Pb) radiometric techniques to an age of approximately 1.88 billion years, a process that directly confirms the presence of the parent uranium isotopes necessary for radon generation.23

Furthermore, the Quetico Subprovince, a major geological belt within the Superior Province, is documented as having an anomalously high background concentration of uranium, making it an important regional source rock for radon that can migrate into adjacent areas.22 An inventory of mineral occurrences in the region has cataloged numerous specific uranium deposits, many of which are high-grade vein-type deposits associated with the unconformity (the ancient erosional surface) between the Proterozoic and Archean rocks.22 These documented occurrences provide concrete evidence that the raw geological ingredient for a significant radon problem—uranium—is widespread throughout the region’s bedrock. The elevated radon levels measured in local homes are, therefore, a direct and predictable outcome of this specific geological endowment.

2.3 From Bedrock to Basement: Radon Transport and Emanation

The presence of uranium-bearing rock is only the first part of the equation. For radon to pose a risk, it must be able to travel from its source in the bedrock to the surface and into buildings. The geological history of the Thunder Bay region has created an efficient transport system for this gas.

The area sits at the edge of the Mid-Continent Rift System, an ancient tectonic feature where the North American continent began to pull apart approximately 1.1 billion years ago.20 This process stretched and broke the hard, brittle rocks of the Canadian Shield, creating a network of deep-seated geological faults and fractures.20 These structural features act as natural conduits or “superhighways” for radon gas, allowing it to bypass less permeable layers of rock and soil and move more readily toward the surface.26 The association of high-grade uranium occurrences with fault and shear zones further highlights the role of these structures in concentrating and transporting radon’s parent elements.22

Once the radon reaches the near-surface environment, the final stage of its journey is through the soil, which is composed of weathered material from the underlying bedrock. The physical properties of this soil, such as its permeability (how easily gas can pass through it), grain size, and moisture content, play a critical role in determining how much radon is released (emanated) from the soil particles and how quickly it is transported.27 For instance, fine-grained, clay-rich soils can sometimes inhibit radon transport, but they can also trap the gas, especially when saturated with water, leading to a buildup of pressure that can force radon into a home.31 The structural geology (faults) and the surficial geology (soil type) are therefore just as important as the chemical geology (uranium content) in determining the ultimate radon risk at a specific location. This helps explain why radon levels can vary significantly over short distances, as a house built over a fracture zone may have a much higher radon ingress potential than a neighboring house on solid, unfractured bedrock.

Section 3: A Hyperlocal Analysis of Radon Concentrations in Thunder Bay

While the regional geology establishes a high potential for radon, public health studies provide the crucial data on actual indoor concentrations, revealing where this potential is being realized. The data for Thunder Bay demonstrates a clear and concerning trend of elevated radon levels that are not only significantly higher than provincial and national averages but also vary dramatically from one neighborhood to the next. This hyperlocal distribution of risk is a key finding for residents, particularly those living near the Lake Superior shoreline.

3.1 The City-Wide Anomaly: Contextualizing the Data

A pivotal study conducted during the winter of 2014-2015 by the Thunder Bay District Health Unit (TBDHU) provides the most detailed local picture of residential radon. The study distributed 468 long-term test kits to homes across the city.33 The results showed that, on average, 16% of homes in Thunder Bay had radon concentrations exceeding Health Canada’s action guideline of $200 Bq/m^3$.5

To put this figure in perspective, the 16% prevalence rate is more than three times higher than the Ontario provincial average of 4.6% and more than double the Canadian national average of 6.9% reported in a 2012 Health Canada survey.4 While a more recent 2024 national survey suggests the Canadian average has risen to approximately 17.8%, the 2015 finding for Thunder Bay was already indicative of a significant local anomaly.26 The TBDHU’s own analysis of the earlier Health Canada data for its specific health region had already pointed to an elevated risk, finding that 12% of homes in the district were above the guideline, a rate 50% higher than the Ontario average at the time.33

This trend of elevated radon is not confined to the city limits but appears to be amplified in the surrounding rural and semi-rural areas. A subsequent TBDHU study in 2018 found an astonishing 65% of tested homes in the neighboring municipality of Oliver Paipoonge exceeded the guideline.5 Similarly, 17% of homes in Marathon, a community on the shore of Lake Superior, were found to be above the action level.5 This pattern suggests a gradient of increasing risk in less densely developed areas, a finding consistent with the 2015 Thunder Bay study, which noted that radon prevalence increased in the more rural parts of the city.36

3.2 A Ward-by-Ward Breakdown: Identifying the Hotspots

The most striking finding of the 2015 TBDHU study was the extreme variation in radon prevalence among the city’s seven municipal wards. The risk is far from uniform, with some neighborhoods exhibiting rates comparable to the highest-risk areas in Canada, while others show virtually no issue. This demonstrates that a resident’s risk is more accurately predicted by their specific ward than by the city’s overall average.

The study identified several distinct high-risk zones 5:

  • High-Risk Wards:
    • McIntyre Ward: Exhibited the highest prevalence, with 43% of tested homes measuring above $200 Bq/m^3$.
    • Neebing Ward: Showed a similarly alarming rate, with 30% of homes exceeding the guideline.
    • Red River Ward: Had 15% of homes above the action level.
    • Current River Ward: Had 13% of homes above the action level.
  • Moderate- to Low-Risk Wards:
    • McKellar Ward: 6% of homes tested above the guideline.
    • Northwood Ward: 3% to 5% of homes tested above the guideline (reports vary slightly).14
    • Westfort Ward: Remarkably, 0% of the homes tested in this ward were found to have elevated radon levels.

This dramatic disparity means that public health messaging based on the 16% city average would dangerously understate the risk for a resident in McIntyre Ward (where the chance of having high radon was nearly 1 in 2 in the study) while simultaneously overstating it for a resident in Westfort Ward. Effective risk communication and public health interventions in Thunder Bay must therefore be geographically targeted at the ward level.

3.3 Focus on the Waterfront: Correlating Risk with Proximity to Lake Superior

By cross-referencing the ward-level radon data with municipal maps, a clear geographical pattern emerges that directly addresses the question of radon potential near Lake Superior.38 The analysis confirms that wards with significant shoreline or proximity to the lake are among the highest-risk areas in the city.

The following table synthesizes the 2015 TBDHU study data with the geographical location of each ward, highlighting the correlation between radon risk and proximity to Lake Superior.

City WardHomes Tested% with Radon > 200 Bq/m³Geographical Location & Proximity to Lake Superior
McIntyre8243%Northern, semi-rural ward located near the lake’s northern shoreline.
Neebing4730%Southern, semi-rural ward with shoreline and proximity to the lake.
Red River7915%Northeastern ward with extensive direct shoreline on Thunder Bay.
Current River6813%North-central ward with direct shoreline on Thunder Bay.
McKellar636%Central ward, set back from the immediate shoreline but still in the eastern half of the city.
Northwood753-5%Central-western ward, located further inland from the lake.
Westfort540%Southwestern ward, located furthest inland from the high-risk shoreline areas.
Data Sources: 14

The data clearly illustrates that the wards directly on or near the Lake Superior shoreline (Current River, Red River, McIntyre, Neebing) all exhibit elevated radon prevalence, well above provincial and national averages. In contrast, the wards located further inland and to the west (Northwood, Westfort) show progressively lower risk, culminating in the 0% prevalence found in the most inland southwestern ward, Westfort.

This distinct spatial pattern, with a clear northeast-to-southwest gradient of decreasing risk, strongly suggests a direct link to the underlying geology discussed in Section 2. It is highly probable that the boundaries of the high-radon wards align with the surface expression of the high-uranium-potential geological formations, such as the Gunflint and Rove Formations. The public health data serves as a surface-level reflection of the subterranean geological map, providing a powerful, unified explanation for the observed radon distribution.

Section 4: Environmental Dynamics: The Lake Effect and Other Influences

The underlying geology provides the source of radon, but the amount that ultimately enters and accumulates in a home is heavily influenced by a complex interplay of environmental and building-specific factors. For communities near Lake Superior, the lake itself creates a unique microclimate that can significantly amplify the mechanisms of radon intrusion. This section explores how the “lake effect,” combined with soil conditions and housing characteristics, can create a “perfect storm” for elevated indoor radon levels.

4.1 Lake Superior’s Climatic Influence on Building Physics

Lake Superior, due to its immense size and thermal mass, exerts a powerful moderating influence on the local climate. This “lake effect” results in cooler air temperatures near the shore during the summer and relatively warmer temperatures in the fall and early winter compared to areas further inland.39 This climatic influence can extend up to 16 kilometers from the shoreline.39 While this effect is well-known for its impact on weather, it also has direct consequences for the physics of buildings along the coast.

A primary mechanism driving radon entry into homes is the thermal stack effect.41 During the heating season, the warm air inside a house is less dense than the cold air outside. This warm air rises and escapes through small openings in the upper levels of the home (e.g., attics, window frames). This outflow of air creates a slight negative pressure, or vacuum, in the lower levels of the house, particularly the basement. This pressure differential causes the house to act like a chimney, actively drawing in replacement air from the surrounding environment. A significant portion of this replacement air is pulled directly from the soil through the foundation, bringing soil gas—and any radon it contains—with it.

The proximity to Lake Superior can amplify this effect. During the cold heating season, when radon testing is most effective and levels are typically highest, the air temperatures near the lake are often colder and denser than those further inland.40 This creates a larger temperature and pressure difference between the heated interior of a lakeside home and the outside air. A larger differential powers a stronger and more persistent stack effect, meaning the house “sucks” harder on the surrounding soil, potentially increasing the rate of radon infiltration compared to an identical home in a warmer, inland location.42

4.2 The Role of Soil, Water, and Ice

The condition of the ground surrounding a home’s foundation is another critical variable. Precipitation, soil moisture, and ground frost play a significant role in controlling radon transport.

When the ground becomes saturated with water from heavy rain or snowmelt, the pore spaces between soil particles fill with water. This creates a barrier that is much less permeable to gas than dry soil, effectively inhibiting radon’s ability to escape harmlessly into the atmosphere.43 Similarly, a layer of dense, wet snow or frozen ground in winter can create a “capping effect,” sealing the ground surface.32

This capping action traps radon gas in the soil beneath and around the home’s foundation, causing its concentration and pressure to build up. With its primary upward escape route blocked, the pressurized soil gas follows the path of least resistance to a lower-pressure area. This path is often directly into the home’s basement, which is simultaneously creating a low-pressure zone via the stack effect.42 The combination of these two phenomena—the ground “pushing” radon due to the capping effect and the house “pulling” radon due to the stack effect—creates a powerful mechanism for radon intrusion. Given the potential for heavy lake-effect snow and saturated soils in the spring and fall, lakeside communities in Thunder Bay are particularly susceptible to this amplified push-pull dynamic.

4.3 The Built Environment: The Final Determinant

While geology and climate create the potential for high radon, the final concentration inside a specific home is ultimately determined by the characteristics of the building itself.41 Two houses built side-by-side on the same soil can have vastly different radon levels due to variations in construction and maintenance.

Key building factors include the type of foundation (e.g., full basement, slab-on-grade, crawl space), the integrity of the foundation (the number and size of cracks and openings), how well penetrations for pipes and utilities are sealed, and the overall ventilation rate of the home.2

The TBDHU study’s finding that homes built between the 1990s and early 2000s had the highest radon levels is particularly instructive.27 This period corresponds to a time when construction practices were increasingly focused on creating more airtight, energy-efficient homes to reduce heating costs. While effective for energy conservation, this increased airtightness also reduces the natural rate of air exchange with the outdoors. If the rate of radon entry remains constant, but the rate of removal through ventilation decreases, the radon gas will accumulate to a higher equilibrium concentration inside the home. These homes were built after the push for energy efficiency but before the widespread adoption of radon-specific preventative measures in the Ontario Building Code, placing them in a potential “sweet spot” for radon accumulation.

The following table summarizes the key factors that converge to influence radon levels in lakeside environments like Thunder Bay.

Influencing FactorMechanismTypical Effect on Indoor Radon
Uranium-Rich GeologySource of radon gas through radioactive decay.Increases the baseline radon potential of the soil.
Geological FaultsProvide high-permeability pathways for gas transport.Facilitates efficient movement of radon from bedrock to the surface.
Lake-Cooled Air (Winter)Increases indoor-outdoor temperature differential.Amplifies the thermal stack effect, increasing the “pull” of radon into the home.
Winter HeatingCreates warm, rising air inside the home.Drives the thermal stack effect, creating negative pressure in the basement.
Saturated/Frozen SoilCreates a low-permeability “cap” on the ground surface.Traps radon in the soil, increasing pressure and the “push” of radon into the home.
Foundation Cracks/GapsProvide direct entry points for soil gas.Allows radon to bypass the solid foundation and enter the building.
Low Home VentilationReduces the rate of air exchange with the outdoors.Allows radon that enters to accumulate to higher concentrations.
Data Synthesis from: 20

In conclusion, the elevated risk in Thunder Bay’s lakeside communities is not attributable to a single factor. It is the result of a synergistic system where high-potential geology provides the source, the unique lake-influenced climate and soil conditions create a powerful push-pull intrusion mechanism, and the specific characteristics of each home determine its ultimate vulnerability.

Section 5: A Practical Guide to Radon Testing and Mitigation in Thunder Bay

Given the documented high radon potential and significant hyperlocal variability in Thunder Bay, moving from analysis to action is critical for protecting resident health. Because radon levels can differ dramatically even between adjacent homes, the only way to determine the risk in any specific building is to conduct a test.5 This section provides a practical, locally-focused guide for residents on how to test their homes, interpret the results, and take effective remedial action if necessary.

5.1 Empowering Homeowners Through Testing

Testing for radon is a simple and inexpensive process that can be undertaken by homeowners themselves or by hiring a certified professional.

Long-Term Testing is the Standard

Radon levels in a home are not static; they fluctuate constantly due to changes in weather, temperature, and ventilation patterns.1 A short-term test of a few days may not capture the true average exposure. For this reason, Health Canada strongly recommends conducting a long-term test for a minimum of three months (91 days).1 This duration is sufficient to average out short-term fluctuations and provide a reliable estimate of a home’s annual average radon concentration, which is the basis for the Canadian guideline.9 The ideal time to conduct this testing is during the heating season (fall and winter), as homes are typically more sealed, and the stack effect is strongest, leading to the highest and most representative radon concentrations.1

Local Do-It-Yourself (DIY) Testing Options

Several accessible and affordable options are available for residents in the Thunder Bay area:

  • EcoSuperior Environmental Programs: This local organization is a primary resource for radon testing. They sell long-term alpha track detector kits for $50, a price that includes the device, instructions, and the subsequent laboratory analysis fees.14 Kits can be ordered online for pickup.
  • Public Library Loan Programs: In a progressive public health initiative, several libraries in Northwestern Ontario, including all branches of the Thunder Bay Public Library, offer digital radon detectors for loan to library card holders.16 These electronic monitors provide real-time and average readings, offering a convenient way for residents to screen their homes. Other participating libraries include those in Oliver Paipoonge, Nipigon, Red Rock, Dorion, and Marathon.16
  • Hardware and Building Supply Stores: Long-term test kits can also be purchased at some local hardware stores or online from various certified organizations.12

When placing a test device, it is crucial to follow the instructions carefully. The device should be placed in the lowest level of the home that is regularly occupied for four or more hours per day (e.g., a basement family room or bedroom) and left undisturbed for the entire testing period.6

Hiring a Measurement Professional

For residents who prefer a professional service, the alternative is to hire a measurement professional certified by the Canadian National Radon Proficiency Program (C-NRPP).12 These professionals are trained in proper testing protocols to ensure accurate and reliable results.

5.2 Interpreting Your Results and Taking Action

Once a long-term test is complete and the results are received from the lab, they should be compared to the Health Canada guideline.

  • If the result is below $200 Bq/m^3$: No immediate action is required. However, given the ALARA principle, homeowners may still consider simple measures to further reduce levels. It is also good practice to re-test every few years or after any major renovations.
  • If the result is between $200 Bq/m^3$ and $600 Bq/m^3$: Remedial action should be taken within two years.12
  • If the result is above $600 Bq/m^3$: Remedial action should be taken within one year.12

It is important to remember that even very high radon levels can be successfully reduced. A high test result is not a reason to panic but a clear signal to take corrective action.49 Radon mitigation systems are highly effective, often reducing indoor levels by over 80-90%, and can typically be installed in less than a day at a reasonable cost, generally ranging from $500 to $3,000.27

5.3 Professional Solutions: Mitigation in the Lakehead

When radon levels are found to be above the guideline, the most common and reliable solution is the installation of an Active Soil Depressurization (ASD) system, also known as Sub-Slab Depressurization (SSD).9 This method involves inserting a small pipe through the foundation floor into the soil or gravel layer beneath. This pipe is connected to a fan, usually located in the attic or outside the home, which runs continuously. The fan creates a permanent low-pressure field under the foundation, constantly drawing radon-laden soil gas from beneath the home and safely venting it above the roofline before it has a chance to enter the living space.9

While sealing major cracks in the foundation and ensuring a sealed lid on sump pits are important supplementary measures, they are rarely sufficient on their own to solve a significant radon problem.2 An ASD system is the gold standard for effective, long-term radon reduction.

To ensure a mitigation system is designed and installed correctly and effectively, it is essential to hire a C-NRPP certified mitigation professional.9 These contractors have the training and diagnostic tools to determine the best location for the suction point and the appropriate fan size for the home’s specific conditions.

Table 2: Directory of Radon Testing and Mitigation Resources in Thunder Bay

The following is a directory of local and regional resources for obtaining test kits and professional services.

Resource CategoryProviderContact InformationServices Offered
DIY Test Kit ProvidersEcoSuperior Environmental Programs562 Red River Road, Thunder Bay, ONSells C-NRPP approved long-term alpha track test kits ($50, includes lab fees). Provides public education and workshops. 16
Public Library Loan ProgramsThunder Bay Public LibrariesAll branchesLoans digital radon detectors to library members for short-term screening. 16
Oliver Paipoonge Public LibrariesMurillo & Rosslyn BranchesLoans digital radon detectors. 16
C-NRPP Certified ProfessionalsCanada Radon(807) 788-3245 | info@canadaradon.comCertified for radon measurement and mitigation. 16
EXP Services Inc.(807) 623-9495 | kristof.karpiuk@exp.comCertified for radon measurement. 16
First General Services(807) 623-1276 | mark.johnson@firstgeneral.caCertified for radon measurement. 16
Northern Home Designs(807) 344-4567 | northernhomedesigns@shaw.caCertified for radon measurement. 16
Radon Safe Northwest Ltd.(807) 626-3049 | stjarre@tbaytel.netCertified for radon measurement. 16
SASI Water(807) 622-8880 | andrew@sasi.caCertified for radon measurement. 16
TBT Engineering(807) 624-5160 | dsteele@tbte.caCertified for radon measurement. 16
Stantec(807) 626-5640 | hwilson@tgcl.caCertified for radon measurement. 16
Note: The list of certified professionals is subject to change. Always verify current certification status through the C-NRPP website.

Section 6: Concluding Analysis and Strategic Recommendations

This report has synthesized geological data, public health studies, and environmental science to construct a comprehensive risk profile for residential radon in Thunder Bay, with a specific focus on communities proximate to Lake Superior. The evidence points to a clear and significant public health concern that requires targeted awareness and action from residents and policymakers.

6.1 Synthesized Risk Profile for Residents Near Lake Superior

The potential for elevated indoor radon levels in homes located within several blocks of the Lake Superior shoreline in Thunder Bay is significantly higher than provincial and former national averages. This conclusion is based on a convergence of multiple, compounding risk factors that create a uniquely challenging environment.

The heightened risk is not attributable to a single cause but is the result of a synergistic system comprising three primary elements:

  1. Geological Predisposition: The city is founded upon the ancient, mineral-rich bedrock of the Canadian Shield. Specific geological units underlying the region, notably the Quetico Subprovince and the Gunflint and Rove Formations, are known to have high background levels of uranium, the ultimate source of radon gas.
  2. Climatic Amplification: The microclimate created by Lake Superior directly influences the physics of radon intrusion. During the long heating season, colder lakeside air temperatures can amplify the thermal stack effect, causing homes to draw more forcefully on the surrounding soil. Simultaneously, lake-effect precipitation can lead to saturated or frozen ground, creating a “capping effect” that traps radon and increases subsurface gas pressure. This combination creates a powerful “push-pull” mechanism that drives radon into basements.
  3. The Built Environment: The final indoor concentration is determined by a home’s specific construction, age, and maintenance. The extreme hyperlocal variability observed in the TBDHU study—with prevalence rates ranging from 43% in McIntyre Ward to 0% in Westfort Ward—underscores that while the environment creates the potential, the house itself determines the final exposure level.

The data from the 2015 TBDHU study confirms this risk is most pronounced in the wards with direct shoreline or a semi-rural character near the lake: McIntyre, Neebing, Red River, and Current River. Residents in these areas face a demonstrably higher probability of living in a home with radon concentrations that exceed the Canadian action guideline. Due to this extreme variability, predictive risk mapping is insufficient for individual decision-making. Therefore, individual home testing is not merely a recommendation but an essential health and safety measure for all residents in these high-potential areas.

6.2 Recommendations for Homeowners, Buyers, and Renters

Based on this analysis, the following actions are recommended for residents and stakeholders in Thunder Bay:

  • For Current Homeowners:
    • Test Your Home: All homeowners, particularly those residing in the high-risk wards (McIntyre, Neebing, Red River, Current River) or in homes built between the 1980s and the early 2000s, should conduct a long-term radon test (minimum three months) during the heating season.
    • Mitigate if Necessary: If test results exceed $200 Bq/m^3$, contract a C-NRPP certified mitigation professional to install a radon reduction system.
    • Inform Yourself: Utilize local resources like EcoSuperior and the Thunder Bay District Health Unit to learn more about radon risks and solutions.
  • For Prospective Home Buyers:
    • Make Radon Testing a Condition: A long-term radon test should be considered a standard and non-negotiable condition in any offer to purchase a home in Thunder Bay, akin to a professional home inspection. If time is a constraint, a short-term test can be used for screening, followed by a long-term test post-occupancy.
    • Inquire About Existing Systems: When viewing a property, ask if a radon mitigation system is already installed. If so, request documentation of its installation and post-mitigation test results to ensure it is functioning effectively.
  • For Renters and Landlords:
    • Collaborate on Testing: Both tenants and landlords should be aware that high radon is a potential health hazard in rental properties. Public health units may respond to tenant complaints regarding high radon levels in a manner similar to other health hazards.33 Open communication and collaborative testing are encouraged.
    • Landlord Responsibility: Landlords have a responsibility to provide a safe living environment, and addressing a confirmed high radon level falls within this purview.
  • For Public Policy and Health Agencies:
    • Continue Targeted Awareness: The TBDHU and its partners should continue public awareness campaigns, using ward-specific data to communicate risk more effectively and motivate testing in the highest-risk neighborhoods.
    • Promote Testing at Point of Sale: As recommended in the TBDHU’s 2015 report, the City of Thunder Bay should strongly consider adopting a bylaw or policy that requires all new homes to be tested for radon prior to sale and encourages testing during all real estate transactions.33
    • Support Financial Accessibility: All levels of government should explore programs to make radon testing and mitigation more financially accessible for low-income households, ensuring that financial constraints do not become a barrier to health and safety.33

Counter-Strike 1.6 Discord Bot

Announcing: A Smart Two-Way Bridge for Counter-Strike 1.6 and Discord!

Ever wanted to see what’s happening on your classic Counter-Strike 1.6 server right from Discord? Or maybe let your Discord community chat with players in-game, even if they’re not at their PC?

I’ve built a new Python bot that does just that, creating a seamless, two-way bridge between your CS 1.6 server and a designated Discord channel. It’s a lightweight, smart solution to keep your community connected.

What It Does: The Key Features

This isn’t just a simple log-dumper. It’s a smart bridge designed to be useful, not noisy.

  • Smart Human Detection: This is the best part. The bot stays quiet and suppresses all server messages (like map changes or bot-only kills) when the server is empty. As soon as the first human player joins, the bot wakes up and starts relaying all activity. When the last human leaves, it goes quiet again. No more spamming your Discord channel when no one is playing!
  • Two-Way Chat: Messages from Discord are sent directly into the in-game chat. In-game messages (
    say
    and
    say_team
    ) are relayed to Discord, complete with team icons (🔵/🔴) and even a tombstone emoji (🪦) if the player is dead.
  • Full Game Event Reporting: Get real-time updates for all important events:
    • Kills:
      Player A killed Player B with an awp 💥
    • Joins/Leaves:
      👋 Player C joined the server
    • Bomb Events:
      💣 The bomb has been planted!
    • Round/Win Events:
      🔄 Round started!
      or
      🔴 Terrorists Win! (CT 5, T 2)
  • GeoIP Player Flags: See where your players are from! The bot automatically looks up player IPs and assigns a country flag (e.g., 🇨🇦, 🇺🇸). Bots get a 🤖 icon, and any failed human lookups get a 🏳️.

How It Works

The bot is built on two simple principles:

  1. UDP Log Listener: You configure your CS 1.6 server to send its logs in real-time to the bot (using the
    logaddress_add
    command). The bot listens on a port (default:
    800
    8), parses these logs, and formats them for Discord.
  2. Asynchronous RCON Client: When a user types a message in your Discord channel, the bot uses RCON (the server’s remote-control protocol) to send a
    say
    command to the game, broadcasting the message to all players.

How to Set Up Your Own Bridge

Ready to try it? You’ll need a server (a simple VPS or even a home machine) to run the Python script.

Step 1: Get the Script & Install Dependencies

First, save the Python code above as

cs_discord_bridge.py
.

You’ll need Python 3 installed. Then, install the required Python libraries using pip:

Bash


pip install discord.py geoip2 tomli

(Note:

tomli
is used as a fallback for
tomllib
on Python versions older than 3.11).

Step 2: Get the GeoIP Database

For the country flag feature, you need to download the free GeoLite2 Country database from MaxMind.

  1. Go to the MaxMind GeoLite2 free database page and sign up.
  2. Download the “GeoLite2-Country” database (it will be a
    .mmdb
    file).
  3. Place this file (e.g.,
    GeoLite2-Country.mmdb
    ) on your server in a location the bot can access.

Step 3: Create Your
config.toml

Next, create a file named

config.toml
in the same directory as your Python script. This is where you’ll put all your settings.

Here is a template. You must fill in the required values.

Ini, TOML


# --- Required Settings ---

# Your Discord Bot Token (get from Discord Developer Portal)
discord_token = "YOUR_BOT_TOKEN_HERE"

# The ID of the Discord channel you want to bridge
discord_channel_id = "YOUR_CHANNEL_ID_HERE"

# Your Counter-Strike 1.6 server's IP or hostname
cs_host = "12.34.56.78"

# Your Counter-Strike 1.6 server's RCON port (usually the same as game port)
cs_port = 27015

# Your server's RCON password (from server.cfg)
cs_rcon_password = "YOUR_RCON_PASSWORD_HERE"

# --- Optional Settings ---

# Path to the GeoIP database you downloaded in Step 2
geoip_db = "/path/to/GeoLite2-Country.mmdb"

# The IP address for the bot to listen on.
# "0.0.0.0" is usually correct to listen on all available IPs.
listen_host = "0.0.0.0"

# The port for the bot to listen for CS logs on.
# This MUST match what you set in Step 4.
listen_port = 8008

# Set to 'true' to allow bot-on-bot kills and other bot-only
# activity to be posted even when no humans are on.
allow_bot_messages = false

# The prefix for bot commands in Discord (e.g., !players)
# The bridge will ignore messages starting with this.
discord_prefix = "!"
  • To get a Bot Token: You need to create an “Application” in the Discord Developer Portal. Create a Bot, and be sure to enable the Message Content Intent under the “Bot” tab.
  • To get a Channel ID: In Discord, enable Developer Mode (Settings > Advanced), then right-click your channel and select “Copy ID”.

Step 4: Configure Your CS 1.6 Server

Now, tell your Counter-Strike server to send its logs to your bot.

Add the following line to your server’s

server.cfg
or
autoexec.cfg
. Replace
BOT_SERVER_IP
with the IP of the machine where your Python script is running, and make sure the port matches your
listen_port
from
config.toml
.


log on
logaddress_add BOT_SERVER_IP:8008

You will need to restart your server (or change maps) for this to take effect.

Step 5: Run the Bot!

You’re all set. Go to the directory with your script and config file and run:

Bash


python3 cs_discord_bridge.py

If all goes well, you’ll see console messages indicating it has logged into Discord and is listening for logs. Now, when you join your server, you should see the activity pop up in your Discord channel!


That’s it! You now have a smart, modern bridge to your classic CS 1.6 server. Feel free to grab the code and try it out.

https://sbmesh.com/bot.py


#!/usr/bin/env python3
"""
cs_discord_bridge.py — Two-way Discord ↔ Counter-Strike 1.6 bridge
Features:
 - Chat relay (say, say_team) with tombstone for dead players
 - Join / leave (posts leave messages always)
 - Kill events (killer → victim, with flags, headshots)
 - Suicide / world kills
 - Bomb events (spawned, dropped, got, planted, defused, bombed) with 💣 emoji
 - Discord → CS chat via asynchronous RCON client
 - Consistent GeoIP flags (🤖 for bots, 🏳️ for failed human lookups)
 - Enhanced debugging for shell command execution
 - Round start/end and team win messages with scores
 - Map change messages suppressed until human detected, re-suppressed when all humans leave
 - Messages sent when human player (non-BOT SteamID) detected in logs
 - Message suppression for non-disconnect events when no humans

Listen port set to 8008 for CS logs.
"""

import asyncio
import datetime
import os
import re
from typing import Optional
from io import BytesIO

try:
    import tomllib  # py311+
except ModuleNotFoundError:
    import tomli as tomllib

import discord
from discord.ext import commands
import geoip2.database

def cc_to_flag(cc: str) -> str:
    if not cc or len(cc) != 2:
        return "🏳️"  # White flag for failed GeoIP lookups for humans
    base = 127397
    return chr(ord(cc[0].upper()) + base) + chr(ord(cc[1].upper()) + base)

# --- Asynchronous RCON Client Class (UDP Version) ---
class RconClient:
    def __init__(self, host, port, password):
        self.host = host
        self.port = port
        self.password = password
        self.packet_size = 1024

    async def get_challenge(self):
        loop = asyncio.get_running_loop()
        on_done = loop.create_future()
       
        message = b'\xFF\xFF\xFF\xFFgetchallenge\n'

        class ChallengeProtocol(asyncio.DatagramProtocol):
            def __init__(self):
                self.transport = None
                self.future = on_done

            def connection_made(self, transport):
                self.transport = transport
                self.transport.sendto(message)

            def datagram_received(self, data, addr):
                try:
                    challenge = data[5:].decode().split(' ')[1].strip()
                    self.future.set_result(challenge)
                except (IndexError, UnicodeDecodeError) as e:
                    self.future.set_exception(ValueError(f"Invalid challenge response: {e}"))
                finally:
                    self.transport.close()

            def error_received(self, exc):
                self.future.set_exception(exc)
                self.transport.close()
       
        try:
            transport, protocol = await loop.create_datagram_endpoint(
                ChallengeProtocol,
                remote_addr=(self.host, self.port)
            )
            return await asyncio.wait_for(on_done, timeout=5.0)
        except asyncio.TimeoutError:
            print(f"[{now_iso()}] RCON challenge request timed out.")
        except Exception as e:
            print(f"[{now_iso()}] [ERROR] Getting RCON challenge: {e}")
        return None

    async def send_command(self, command: str):
            challenge = await self.get_challenge()
            if not challenge:
                print(f"[{now_iso()}] Cannot send command, failed to get RCON challenge.")
                return

            loop = asyncio.get_running_loop()
            on_done = loop.create_future()

            message_buffer = BytesIO()
            message_buffer.write(b'\xFF\xFF\xFF\xFF')
            message_buffer.write('rcon '.encode())
            message_buffer.write(challenge.encode())
            message_buffer.write(b' ')
            message_buffer.write(self.password.encode())
            message_buffer.write(b' ') # New line
            message_buffer.write(command.encode()) # New line
            message_buffer.write(b'\n')

            message_bytes = message_buffer.getvalue()

            class CommandProtocol(asyncio.DatagramProtocol):
                def __init__(self):
                    self.transport = None
                    self.future = on_done

                def connection_made(self, transport):
                    self.transport = transport
                    self.transport.sendto(message_bytes)
                    self.future.set_result(None)
                    self.transport.close()

                def error_received(self, exc):
                    self.future.set_exception(exc)
                    self.transport.close()

            try:
                transport, protocol = await loop.create_datagram_endpoint(
                    CommandProtocol,
                    remote_addr=(self.host, self.port)
                )
                await asyncio.wait_for(on_done, timeout=5.0)
                print(f"[{now_iso()}] Sent RCON command: {command}")
            except asyncio.TimeoutError:
                print(f"[{now_iso()}] RCON command timed out.")
            except Exception as e:
                print(f"[{now_iso()}] [ERROR] Sending RCON command: {e}")

# --- Regexes ---
HL_SAY_REGEXES = [
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" say "(.*)"(?: \(dead\))?$'),
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" say_team "(.*)"(?: \(dead\))?$'),
]

CONNECT_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" connected, address "(.+?):(\d+)"$')

JOIN_LEAVE_REGEXES = [
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" entered the game$'),
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" disconnected(?: \(reason "(.*?)"\))?$'),
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" joined team "(.*)"$'),
]

KILL_REGEX = re.compile(
    r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" killed "(.+?)<\d+><(.*?)><(.*?)>" with "(.*?)"(?: \(headshot\))?$'
)

SUICIDE_REGEXES = [
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" committed suicide with "(.*?)"$'),
    re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" killed self with "(.*?)"$'),
]

BOMB_REGEX = re.compile(
    r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" triggered "(Spawned_With_The_Bomb|Dropped_The_The_Bomb|Got_The_Bomb|Planted_The_Bomb)"'
)

TEAM_BOMB_REGEX = re.compile(
    r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Team "(CT|TERRORIST)" triggered "(Bomb_Defused|Target_Bombed)" \(CT "(\d+)"\) \(T "(\d+)"\)'
)

STEAM_VALIDATE_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)>" STEAM USERID validated$')

RCON_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Rcon: "rcon (.+)" from "(.+)"$')

MAPCHANGE_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Started map "(.+)" \(CRC ".+"')

STARTED_MAP_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Started map "(.+?)"')

ROUND_START_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: World triggered "Round_Start"$')
ROUND_END_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: World triggered "Round_End"$')
TEAM_WIN_REGEX = re.compile(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Team "(CT|TERRORIST)" triggered "(CTs_Win|Terrorists_Win)" \(CT "(\d+)"\) \(T "(\d+)"\)$')

def now_iso():
    return datetime.datetime.now().astimezone().isoformat(timespec="seconds")

class HLLogUDP(asyncio.DatagramProtocol):
    def __init__(self, on_line):
        super().__init__()
        self.on_line = on_line

    def datagram_received(self, data: bytes, addr):
        try:
            text = data.decode('utf-8', errors='replace').strip()
        except Exception:
            text = repr(data)
        for line in text.splitlines():
            asyncio.create_task(self.on_line(line, addr))

class CSDiscordBridge:
    def __init__(self, cfg: dict):
        self.cfg = cfg
        intents = discord.Intents.default()
        intents.message_content = True
        intents.messages = True  # Ensure message intents are enabled
        self.bot = commands.Bot(command_prefix=cfg.get("discord_prefix", "!"), intents=intents)
        self.channel: Optional[discord.TextChannel] = None

        self.geoip_reader = None
        db_path = self.cfg.get("geoip_db", "/home/csserver/discord/GeoLite2-Country.mmdb")
        try:
            self.geoip_reader = geoip2.database.Reader(db_path)
        except Exception as e:
            print(f"[{now_iso()}] [WARN] GeoIP not available: {e}")
        self.player_flags = {}

        self.connected_players = {}
        self.has_human_player = False
        self.allow_bot_messages = cfg.get("allow_bot_messages", False)
        self.pending_map_change = None
        self.suppress_join_messages = False

        self.bot.event(self.on_ready)
        self.bot.event(self.on_message)

        self.rcon_client = RconClient(
            self.cfg["cs_host"],
            int(self.cfg["cs_port"]),
            self.cfg["cs_rcon_password"]
        )

    async def on_ready(self):
        ch_id = int(self.cfg["discord_channel_id"])
        try:
            self.channel = self.bot.get_channel(ch_id) or await self.bot.fetch_channel(ch_id)
            print(f"[{now_iso()}] Logged in as {self.bot.user}. Bridging to #{self.channel.name} ({self.channel.id})")
        except Exception as e:
            print(f"[{now_iso()}] [ERROR] Failed to fetch Discord channel {ch_id}: {e}")
            return
       
        await self._send_startup_commands()

        listen_host = self.cfg.get("listen_host", "0.0.0.0")
        listen_port = int(self.cfg.get("listen_port", 8008))
        loop = asyncio.get_running_loop()
        print(f"[{now_iso()}] Listening for HL logs on {listen_host}:{listen_port}")
        try:
            await loop.create_datagram_endpoint(
                lambda: HLLogUDP(self.handle_hl_line),
                local_addr=(listen_host, listen_port)
            )
        except Exception as e:
            print(f"[{now_iso()}] [ERROR] Failed to start UDP listener: {e}")

    async def _send_startup_commands(self):
        print(f"[{now_iso()}] Sending RCON command to disable chat prefixes...")
        await self.rcon_client.send_command('amx_chat_prefix ""')
        await self.rcon_client.send_command('cs_chat_prefix ""')
        await self.rcon_client.send_command('sv_say_prefix ""')

    async def on_message(self, message: discord.Message):
        print(f"[{now_iso()}] [DEBUG] Received Discord message: author={message.author}, channel={message.channel.id}, content={message.content}")
        if message.author.bot:
            print(f"[{now_iso()}] [DEBUG] Ignoring message from bot: {message.author}")
            return
        if not self.channel or message.channel.id != self.channel.id:
            print(f"[{now_iso()}] [DEBUG] Message from wrong channel: {message.channel.id}, expected {self.channel.id if self.channel else 'None'}")
            return

        prefix = self.cfg.get("discord_prefix", "!")
        if message.content.startswith(prefix):
            print(f"[{now_iso()}] [DEBUG] Ignoring command with prefix: {message.content}")
            return

        content = self._sanitize_discord(message.content)
        author = message.author.display_name
       
        line = f'say (DISCORD) {author}: {content}'
        print(f"[{now_iso()}] [DEBUG] Processing Discord message: {line} (has_human_player={self.has_human_player})")
        await self._send_rcon_say(line)

    async def _send_rcon_say(self, command: str):
        limit = self.cfg.get("say_length_limit", 190)
        if len(command) > limit:
            command = command[:limit-1] + "…"
       
        print(f"[{now_iso()}] [DEBUG] Sending RCON command to CS: {command}")
        await self.rcon_client.send_command(command)

    async def handle_hl_line(self, line: str, addr):
        if "Server cvar" in line:
            return
        print(f"[{now_iso()}] Received log: {line}")

        # Strip potential garbage prefixes like ����log
        cleaned_line = line
        if line.startswith('\ufffd\ufffd\ufffd\ufffdlog'):
            cleaned_line = line[8:].strip()
            print(f"[{now_iso()}] Stripped garbage prefix, cleaned log: {cleaned_line}")

        log_start_match = re.search(r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}:', cleaned_line)
        if not log_start_match:
            print(f"[{now_iso()}] Unmatched log (no valid start): {cleaned_line}")
            if "connected, address" in cleaned_line and self._parse_connected(cleaned_line):
                return
            return

        cleaned_line = cleaned_line[log_start_match.start():].strip()
        print(f"[{now_iso()}] Cleaned log: {cleaned_line}")

        map_change = self._parse_mapchange(cleaned_line)
        if map_change:
            print(f"[{now_iso()}] Detected map change: {map_change}. Storing for later.")
            self.pending_map_change = map_change
            self.suppress_join_messages = True
            await self._post_to_discord(map_change, force=True)
            return
       
        if self._parse_steam_validated(cleaned_line):
            return
           
        if self._parse_rcon(cleaned_line):
            return

        if self._parse_connected(cleaned_line):
            return

        joinleave = self._parse_join_leave(cleaned_line)
        if joinleave:
            print(f"[{now_iso()}] Parsed join/leave: {joinleave} (has_human_player={self.has_human_player})")
            await self._post_to_discord(joinleave)
            return

        say, team_flag, payload = self._parse_say(cleaned_line)
        if say:
            print(f"[{now_iso()}] Parsed say (team={team_flag}): {payload} (has_human_player={self.has_human_player})")
            await self._post_to_discord(payload)
            return

        kill = self._parse_kill(cleaned_line)
        if kill:
            print(f"[{now_iso()}] Parsed kill: {kill} (has_human_player={self.has_human_player})")
            await self._post_to_discord(kill)
            return

        suicide = self._parse_suicide(cleaned_line)
        if suicide:
            print(f"[{now_iso()}] Parsed suicide: {suicide} (has_human_player={self.has_human_player})")
            await self._post_to_discord(suicide)
            return

        bomb = self._parse_bomb(cleaned_line)
        if bomb:
            print(f"[{now_iso()}] Parsed bomb event: {bomb} (has_human_player={self.has_human_player})")
            await self._post_to_discord(bomb)
            return

        if ROUND_START_REGEX.search(cleaned_line):
            payload = "🔄 Round started!"
            print(f"[{now_iso()}] Parsed round start: {payload} (has_human_player={self.has_human_player})")
            self.suppress_join_messages = False
            await self._post_to_discord(payload)
            return
           
        if ROUND_END_REGEX.search(cleaned_line):
            payload = "🏁 Round ended!"
            print(f"[{now_iso()}] Parsed round end: {payload} (has_human_player={self.has_human_player})")
            await self._post_to_discord(payload)
            return
           
        m = TEAM_WIN_REGEX.search(cleaned_line)
        if m:
            team, _, ct_score, t_score = m.groups()
            if team == "CT":
                payload = f"🔵 **Counter-Terrorists Win!** (CT {ct_score}, T {t_score})"
            else:
                payload = f"🔴 **Terrorists Win!** (CT {ct_score}, T {t_score})"
            print(f"[{now_iso()}] Parsed team win: {payload} (has_human_player={self.has_human_player})")
            await self._post_to_discord(payload)
            return
           
        print(f"[{now_iso()}] Unmatched log: {cleaned_line} (has_human_player={self.has_human_player})")

    def _parse_mapchange(self, line: str) -> Optional[str]:
        m_mapchange = re.compile(
            r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: -------- Mapchange to (.+?) --------$'
        ).search(line)
        m_started_map = re.compile(
            r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: Started map "(.+?)"'
        ).search(line)

        map_name = None
        if m_mapchange:
            map_name = m_mapchange.group(1)
        elif m_started_map:
            map_name = m_started_map.group(1)

        if map_name:
            # snapshot current state before reset
            had_humans = self.has_human_player  

            # reset for the new map
            self.has_human_player = False
            self.connected_players = {}
            self.suppress_join_messages = True

            # Only announce if humans were present or bot messages are allowed
            if had_humans or self.allow_bot_messages:
                return f"🔄 Map changed to `{map_name}`"
            else:
                print(f"[{now_iso()}] Suppressing mapchange announcement ({map_name}) — no human players were online.")
                return None

        return None

    def _parse_steam_validated(self, line: str) -> bool:
        m = STEAM_VALIDATE_REGEX.search(line)
        if m:
            name, steamid, team = m.groups()
            print(f"[{now_iso()}] Handling 'STEAM USERID validated' for {name} ({steamid}).")
            if steamid != "BOT":
                if "STEAM_ID_PENDING" in self.connected_players:
                    pending_name = self.connected_players.pop("STEAM_ID_PENDING")
                    self.connected_players[steamid] = pending_name
                    if "STEAM_ID_PENDING" in self.player_flags:
                        self.player_flags[steamid] = self.player_flags.pop("STEAM_ID_PENDING")
                        print(f"[{now_iso()}] Transferred flag for {pending_name} from STEAM_ID_PENDING to {steamid}: {self.player_flags[steamid]}")
            return True
        return False

    def _parse_rcon(self, line: str) -> bool:
        m = RCON_REGEX.search(line)
        if m:
            command, _ = m.groups()
            if not command.strip():  # Ignore incomplete RCON commands
                print(f"[{now_iso()}] [DEBUG] Ignored incomplete RCON command: {line}")
                return True
            print(f"[{now_iso()}] Handled RCON command log: {command}")
            return True
        return False

    def _parse_connected(self, line: str) -> bool:
        m = CONNECT_REGEX.search(line)
        if m:
            name, steamid, team, ip, port = m.groups()
            print(f"[{now_iso()}] [DEBUG] Matched CONNECT_REGEX for: {name} ({steamid}, IP {ip})")
            if steamid == "BOT":
                print(f"[{now_iso()}] Bot connected: {name} ({steamid})")
                self.player_flags[steamid] = "🤖"
                print(f"[{now_iso()}] Assigned flag code for {name} ({steamid}): 'None' -> '🤖'")
                return True
           
            self.connected_players[steamid] = name
            self.has_human_player = True

            flag = "🏳️"
            country_code = None
            if self.geoip_reader:
                try:
                    response = self.geoip_reader.country(ip)
                    country_code = response.country.iso_code
                    if country_code:
                        flag = cc_to_flag(country_code)
                        print(f"[{now_iso()}] GeoIP lookup for {name} ({steamid}, IP {ip}): country code '{country_code}' -> flag '{flag}'")
                    else:
                        print(f"[{now_iso()}] [WARN] No country code found for IP {ip}")
                except Exception as e:
                    print(f"[{now_iso()}] [ERROR] GeoIP lookup failed for IP {ip}: {e}")
            else:
                print(f"[{now_iso()}] [ERROR] GeoIP reader not initialized for {name} ({steamid})")
           
            self.player_flags[steamid] = flag
            print(f"[{now_iso()}] Assigned flag code for {name} ({steamid}): '{country_code or 'None'}' -> '{flag}'")
            print(f"[{now_iso()}] [DEBUG] GeoIP database path: {self.cfg.get('geoip_db', '/home/csserver/discord/GeoLite2-Country.mmdb')}")
            print(f"[{now_iso()}] [DEBUG] Player flags dictionary: {self.player_flags}")
            return True
        else:
            print(f"[{now_iso()}] [DEBUG] CONNECT_REGEX failed to match: {line}")
        return False

    def _parse_join_leave(self, line: str) -> Optional[str]:
        # Ignore raw connection lines like:  " from "64.188.91.127:53453"
        if ' from "' in line:
            print(f"[{now_iso()}] Ignoring connection info line: {line.strip()}")
            return None

        entered_the_game_regex = re.compile(
            r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)?>" entered the game$'
        )

        m = entered_the_game_regex.search(line)
        if m:
            name, steamid, team = m.groups()[:3]
            if steamid == "BOT":
                self.player_flags[steamid] = "🤖"
                print(f"[{now_iso()}] Stored flag for {name} ({steamid}): 🤖")
                print(f"[{now_iso()}] Ignoring bot join message for {name}.")
                return None
        entered_the_game_regex = re.compile(
            r'L \d{2}/\d{2}/\d{4} - \d{2}:\d{2}:\d{2}: "(.+?)<\d+><(.*?)><(.*?)?>" entered the game$'
        )

        m = entered_the_game_regex.search(line)
        if m:
            name, steamid, team = m.groups()[:3]
            if steamid == "BOT":
                self.player_flags[steamid] = "🤖"
                print(f"[{now_iso()}] Stored flag for {name} ({steamid}): 🤖")
                print(f"[{now_iso()}] Ignoring bot join message for {name}.")
                return None
           
            if self.suppress_join_messages:
                print(f"[{now_iso()}] Suppressing join message for {name} ({steamid}) during map change.")
                self.connected_players[steamid] = name
                self.has_human_player = True
                return None

            self.connected_players[steamid] = name
            self.has_human_player = True
            name = self._sanitize_name(name)
            flag = self.player_flags.get(steamid, "🏳️")
            if flag == "🏳️" and steamid != "BOT":
                print(f"[{now_iso()}] [WARN] No flag found for {name} ({steamid}), attempting GeoIP lookup")
                m_connect = CONNECT_REGEX.search(line)
                if m_connect:
                    _, _, _, ip, _ = m_connect.groups()
                    if self.geoip_reader and ip:
                        try:
                            response = self.geoip_reader.country(ip)
                            country_code = response.country.iso_code
                            if country_code:
                                flag = cc_to_flag(country_code)
                                self.player_flags[steamid] = flag
                                print(f"[{now_iso()}] Fallback GeoIP lookup for {name} ({steamid}, IP {ip}): country code '{country_code}' -> flag '{flag}'")
                            else:
                                print(f"[{now_iso()}] [WARN] No country code found for IP {ip} in fallback lookup")
                        except Exception as e:
                            print(f"[{now_iso()}] [ERROR] Fallback GeoIP lookup failed for IP {ip}: {e}")
            print(f"[{now_iso()}] Human player joined: {name} ({steamid}), flag={flag}, has_human_player={self.has_human_player}")
            return f'👋 {flag} `{name}` joined the server'

        for rx in JOIN_LEAVE_REGEXES:
            m = rx.search(line)
            if m:
                if "disconnected" in line:
                    name, steamid, _, reason = m.groups()
                    if steamid == "BOT":
                        print(f"[{now_iso()}] Ignoring bot leave message for {name}.")
                        return None
                   
                    if self.suppress_join_messages:
                        print(f"[{now_iso()}] Suppressing leave message for {name} ({steamid}) during map change window")
                        if steamid in self.connected_players:
                            del self.connected_players[steamid]
                        return None
                   
                    name = self._sanitize_name(name)
                    flag = self.player_flags.get(steamid, "🏳️")
                    leave_message = f'❌ {flag} `{name}` left the server' + (f' ({reason})' if reason else '')
                    if steamid in self.connected_players:
                        del self.connected_players[steamid]
                        if not any(sid != "BOT" for sid in self.connected_players):
                            self.has_human_player = False
                            print(f"[{now_iso()}] No human players remain, has_human_player={self.has_human_player}")
                    print(f"[{now_iso()}] Human player left: {name} ({steamid}), flag={flag}, has_human_player={self.has_human_player}")
                    return leave_message
                elif "joined team" in line:
                    name, steamid, team, _ = m.groups()
                    if steamid == "BOT":
                        self.player_flags[steamid] = "🤖"
                        print(f"[{now_iso()}] Stored flag for {name} ({steamid}): 🤖")
                        print(f"[{now_iso()}] Ignoring bot joined team message.")
                        return None
                    self.has_human_player = True
                    print(f"[{now_iso()}] Human detected in joined team: {name} ({steamid}), has_human_player={self.has_human_player}")
                    return None
        return None

    def _parse_say(self, line: str):
        for rx in HL_SAY_REGEXES:
            m = rx.search(line)
            if m:
                name, steamid, team, msg = m.groups()
                team_flag = "say_team" in rx.pattern or "say_team" in line
                name = self._sanitize_name(name)
                msg = self._sanitize_cs(msg)
                flag = self.player_flags.get(steamid, "🤖" if steamid == "BOT" else "🏳️")
                if steamid != "BOT":
                    self.has_human_player = True
                    print(f"[{now_iso()}] Human detected in say: {name} ({steamid}), has_human_player={self.has_human_player}")
                team_icon = "🔵" if team.upper().startswith("CT") else "🔴" if team.upper().startswith("T") else "⚪"
                dead_indicator = " 🪦" if "(dead)" in line else ""
                payload = f'💬 {flag} {team_icon} `{name}`{dead_indicator}: {msg}'
                return True, team_flag, payload
        return False, False, None

    def _parse_kill(self, line: str) -> Optional[str]:
        m = KILL_REGEX.search(line)
        if not m:
            return None
        killer_name, killer_id, killer_team, victim_name, victim_id, victim_team, weapon = m.groups()

        if killer_id == "BOT" and victim_id == "BOT":
            print(f"[{now_iso()}] Ignoring kill message (bot killed bot).")
            return None

        if killer_id != "BOT" or victim_id != "BOT":
            self.has_human_player = True
            print(f"[{now_iso()}] Human detected in kill: {killer_name} ({killer_id}) or {victim_name} ({victim_id}), has_human_player={self.has_human_player}")

        killer_flag = self.player_flags.get(killer_id, "🤖" if killer_id == "BOT" else "🏳️")
        victim_flag = self.player_flags.get(victim_id, "🤖" if victim_id == "BOT" else "🏳️")
        killer_name = self._sanitize_name(killer_name)
        victim_name = self._sanitize_name(victim_name)

        headshot = " 💥" if "(headshot)" in line else ""
        killer_team_icon = "🔵" if killer_team.upper().startswith("CT") else "🔴" if killer_team.upper().startswith("T") else "⚪"
        victim_team_icon = "🔵" if victim_team.upper().startswith("CT") else "🔴" if victim_team.upper().startswith("T") else "⚪"

        # Determine article
        vowel_sounds = ('a', 'e', 'i', 'o', 'u')
        exceptions = {"tmp", "usp", "ump", "uzi"}  # extend if needed
        if weapon.lower() in exceptions:
            article = "a"
        else:
            article = "an" if weapon.lower().startswith(vowel_sounds) else "a"

        return f'{killer_flag} {killer_team_icon} `{killer_name}` killed {victim_flag} {victim_team_icon} `{victim_name}` with {article} *{weapon}*{headshot}'

    def _parse_suicide(self, line: str) -> Optional[str]:
        for rx in SUICIDE_REGEXES:
            m = rx.search(line)
            if m:
                name, steamid, _, weapon = m.groups()
                if steamid != "BOT":
                    self.has_human_player = True
                    print(f"[{now_iso()}] Human detected in suicide: {name} ({steamid}), has_human_player={self.has_human_player}")
                name = self._sanitize_name(name)
                flag = self.player_flags.get(steamid, "🤖" if steamid == "BOT" else "🏳️")
                return f'💀 {flag} `{name}` died ({weapon})'
        return None

    def _parse_bomb(self, line: str) -> Optional[str]:
        m = BOMB_REGEX.search(line)
        if m:
            name, steamid, _, event = m.groups()
            name = self._sanitize_name(name)
            flag = self.player_flags.get(steamid, "🤖" if steamid == "BOT" else "🏳️")
            if steamid != "BOT":
                self.has_human_player = True
                print(f"[{now_iso()}] Human detected in bomb event: {name} ({steamid}), has_human_player={self.has_human_player}")
            if event == "Planted_The_Bomb":
                return '💣 *The bomb has been planted!*'
        m = TEAM_BOMB_REGEX.search(line)
        if m:
            team, event, ct_score, t_score = m.groups()
            if event == "Bomb_Defused":
                return '💣 *The bomb has been defused!*'
            elif event == "Target_Bombed":
                return '💣 *Target successfully bombed!*'
        return None

    async def _post_to_discord(self, content: str, force=False):
        if not self.channel:
            print(f"[{now_iso()}] [ERROR] Discord channel not set, cannot post: {content}")
            return

        # Always allow join/leave messages through (mapchange suppression already handled in _parse_join_leave)
        if force or content.startswith(("👋", "❌")) or self.has_human_player or self.allow_bot_messages:
            try:
                await self.channel.send(content)
                print(f"[{now_iso()}] Successfully posted to Discord: {content} (has_human_player={self.has_human_player})")
            except Exception as e:
                print(f"[{now_iso()}] [ERROR] Failed to send to Discord: {e} (content={content})")
        else:
            print(f"[{now_iso()}] Suppressing Discord message due to no human players: {content} (has_human_player={self.has_human_player})")

    def _sanitize_discord(self, s: str) -> str:
        s = s.replace('"', '').strip()
        return ''.join(ch for ch in s if 31 < ord(ch) < 127)

    def _sanitize_cs(self, s: str) -> str:
        s = s.replace('"', "'")
        return ''.join(ch for ch in s if 31 < ord(ch) < 127)

    def _sanitize_name(self, s: str) -> str:
        s = s.strip()
        if len(s) > 24:
            s = s[:23] + "…"
        return self._sanitize_cs(s)

    async def run(self):
        listen_host = self.cfg.get("listen_host", "0.0.0.0")
        listen_port = int(self.cfg.get("listen_port", 8008))
        loop = asyncio.get_running_loop()
        print(f"[{now_iso()}] Listening for HL logs on {listen_host}:{listen_port}")
        try:
            await loop.create_datagram_endpoint(
                lambda: HLLogUDP(self.handle_hl_line),
                local_addr=(listen_host, listen_port)
            )
        except Exception as e:
            print(f"[{now_iso()}] [ERROR] Failed to start UDP listener: {e}")
        await self.bot.start(self.cfg["discord_token"])

def load_config(path: str) -> dict:
    try:
        with open(path, "rb") as f:
            cfg = tomllib.load(f)
    except Exception as e:
        print(f"[{now_iso()}] [ERROR] Failed to load config {path}: {e}")
        raise SystemExit(f"Failed to load config: {e}")
    required = ["discord_token", "discord_channel_id", "cs_host", "cs_port", "cs_rcon_password"]
    for key in required:
        if key not in cfg:
            print(f"[{now_iso()}] [ERROR] Missing required config key: {key}")
            raise SystemExit(f"Missing required config key: {key}")
    return cfg

async def amain(cfg_path: str):
    cfg = load_config(cfg_path)
    bridge = CSDiscordBridge(cfg)
    await bridge.run()

def main():
    cfg_path = os.environ.get("CSBRIDGE_CONFIG", "config.toml")
    try:
        asyncio.run(amain(cfg_path))
    except KeyboardInterrupt:
        print(f"[{now_iso()}] Exiting...")
    except Exception as e:
        print(f"[{now_iso()}] [ERROR] Fatal error: {e}")

if __name__ == "__main__":
    main()

linux firefox Open With addon python script that works with Brave and Librewolf

https://addons.mozilla.org/en-US/firefox/addon/open-with


#!/usr/bin/env python
from __future__ import print_function

import os
import sys
import json
import struct
import subprocess

VERSION = '7.1b2'

try:
    sys.stdin.buffer

    # Python 3.x version
    # Read a message from stdin and decode it.
    def getMessage():
        rawLength = sys.stdin.buffer.read(4)
        if len(rawLength) == 0:
            sys.exit(0)
        messageLength = struct.unpack('@I', rawLength)[0]
        message = sys.stdin.buffer.read(messageLength).decode('utf-8')
        return json.loads(message)

    # Send an encoded message to stdout
    def sendMessage(messageContent):
        encodedContent = json.dumps(messageContent).encode('utf-8')
        encodedLength = struct.pack('@I', len(encodedContent))

        sys.stdout.buffer.write(encodedLength)
        sys.stdout.buffer.write(encodedContent)
        sys.stdout.buffer.flush()

except AttributeError:
    # Python 2.x version (if sys.stdin.buffer is not defined)
    # Read a message from stdin and decode it.
    def getMessage():
        rawLength = sys.stdin.read(4)
        if len(rawLength) == 0:
            sys.exit(0)
        messageLength = struct.unpack('@I', rawLength)[0]
        message = sys.stdin.read(messageLength)
        return json.loads(message)

    # Send an encoded message to stdout
    def sendMessage(messageContent):
        encodedContent = json.dumps(messageContent)
        encodedLength = struct.pack('@I', len(encodedContent))

        sys.stdout.write(encodedLength)
        sys.stdout.write(encodedContent)
        sys.stdout.flush()


def install():
    home_path = os.getenv('HOME')

    manifest = {
        'name': 'open_with',
        'description': 'Open With native host',
        'path': os.path.realpath(__file__),
        'type': 'stdio',
    }
    locations = {
        'chrome': os.path.join(home_path, '.config', 'google-chrome', 'NativeMessagingHosts'),
        'brave-browser': os.path.join(home_path, '.config', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'),
        'brave': os.path.join(home_path, '.config', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'),
        'chromium': os.path.join(home_path, '.config', 'chromium', 'NativeMessagingHosts'),
        'firefox': os.path.join(home_path, '.mozilla', 'native-messaging-hosts'),
        'librewolf': os.path.join(home_path, '.librewolf', 'native-messaging-hosts'),
    }
    filename = 'open_with.json'

    for browser, location in locations.items():
        if os.path.exists(os.path.dirname(location)):
            if not os.path.exists(location):
                os.mkdir(location)

            browser_manifest = manifest.copy()
            if browser == 'firefox' or browser == 'librewolf':
                browser_manifest['allowed_extensions'] = ['openwith@darktrojan.net']
            else:
                browser_manifest['allowed_origins'] = [
                    'chrome-extension://cogjlncmljjnjpbgppagklanlcbchlno/',  # Chrome
                    'chrome-extension://fbmcaggceafhobjkhnaakhgfmdaadhhg/',  # Opera
                ]

            with open(os.path.join(location, filename), 'w') as file:
                file.write(
                    json.dumps(browser_manifest, indent=2, separators=(',', ': '), sort_keys=True).replace('  ', '\t') + '\n'
                )


def _read_desktop_file(path):
    with open(path, 'r') as desktop_file:
        current_section = None
        name = None
        command = None
        for line in desktop_file:
            if line[0] == '[':
                current_section = line[1:-2]
            if current_section != 'Desktop Entry':
                continue

            if line.startswith('Name='):
                name = line[5:].strip()
            elif line.startswith('Exec='):
                command = line[5:].strip()

        return {
            'name': name,
            'command': command
        }


def find_browsers():
    apps = [
        'Chrome',
        'Chromium',
        'chromium-browser',
        'firefox',
        'Firefox',
        'Google Chrome',
        'google-chrome',
        'opera',
        'Opera',
        'SeaMonkey',
        'seamonkey',
        'brave-browser',
        'brave',
        'librewolf',
    ]
    paths = [
        os.path.join(os.getenv('HOME'), '.local/share/applications'),
        '/usr/local/share/applications',
        '/usr/share/applications'
    ]
    suffix = '.desktop'

    results = []
    for p in paths:
        for a in apps:
            fp = os.path.join(p, a) + suffix
            if os.path.exists(fp):
                results.append(_read_desktop_file(fp))
    return results


def listen():
    receivedMessage = getMessage()
    if receivedMessage == 'ping':
        sendMessage({
            'version': VERSION,
            'file': os.path.realpath(__file__)
        })
    elif receivedMessage == 'find':
        sendMessage(find_browsers())
    else:
        for k, v in os.environ.items():
            if k.startswith('MOZ_'):
                try:
                    os.unsetenv(k)
                except:
                    os.environ[k] = ''

        devnull = open(os.devnull, 'w')
        subprocess.Popen(receivedMessage, stdout=devnull, stderr=devnull)
        sendMessage(None)


if __name__ == '__main__':
    if len(sys.argv) == 2:
        if sys.argv[1] == 'install':
            install()
            sys.exit(0)
        elif sys.argv[1] == 'find_browsers':
            print(find_browsers())
            sys.exit(0)

    allowed_extensions = [
        'openwith@darktrojan.net',
        'chrome-extension://cogjlncmljjnjpbgppagklanlcbchlno/',
        'chrome-extension://fbmcaggceafhobjkhnaakhgfmdaadhhg/',
    ]
    for ae in allowed_extensions:
        if ae in sys.argv:
            listen()
            sys.exit(0)

    print('Open With native helper, version %s.' % VERSION)

CDP 8 / Composers Desktop Project ARM64 aarch64 binaries Raspberry Pi etc

compiled on debian 12 with an orange pi 5

https://sbmesh.com/CDP8aarch64.zip

I’m using it with Renoise CDP Interface tool https://www.renoise.com/tools/cdp-interface

The Composers Desktop Project (CDP) software is a suite of tools developed for in-depth sound manipulation and transformation, aimed primarily at composers and sound designers interested in musique concrète and experimental sound design.

abfdcode
abfpan
abfpan2
analjoin
asciiget
blur
bounce
brkdur
brktopi
brownian
caltrain
cantor
cascade
cdparams
cdparams_other
cdparse
ceracu
channelx
chanphase
chirikov
chorder
chxformat
clicknew
clip
columns
combine
constrict
convert_to_midi
copysfx
crumble
crystal
cubicspline
dirsf
diskspace
distcut
distmark
distmore
distort
distortt
distrep
distshift
dshift
dvdwind
envcut
envel
envnu
envspeak
extend
fastconv
features
filter
filtrage
fixgobo
flatten
flutter
fmdcode
focus
fofex
formants
fractal
fracture
frame
freeze
fturanal
gate
get_partials
getcol
glisten
gobo
gobosee
grain
grainex
hfperm
hilite
histconv
housekeep
hover
hover2
impulse
interlx
isolate
iterfof
iterline
iterlinef
listaudevs
listdate
logdate
madrid
manysil
matrix
maxsamp2
mchanpan
mchanrev
mchiter
mchshred
mchstereo
mchzig
modify
morph
motor
mton
multimix
multiosc
multisynth
newdelay
newmix
newmorph
newscales
newsynth
newtex
njoin
nmix
notchinvert
oneform
onset
packet
pagrab
pairex
panorama
paplay
partition
paudition
paview
pdisplay
peak
peakfind
peakiso
phase
phasor
pitch
pitchinfo
pmodify
prefix
progmach
psow
ptobrk
pulser
putcol
pview
pvoc
pvplay
quirk
recsf
refocus
rejoin
repair
repeater
repitch
retime
reverb
rmresp
rmsinfo
rmverb
rotor
scramble
search
selfsim
sfecho
sfedit
sfprops
shifter
shrink
silend
smooth
sndinfo
sorter
spacedesign
spec
specanal
specav
specenv
specfnu
specfold
specgrids
specinfo
speclean
specnu
specross
specsphinx
spectrum
spectstr
spectune
spectwin
speculate
specvu
spike
spin
splinter
strands
strange
strans
stretch
stretcha
stutter
submix
subtract
superaccu
suppress
synfilt
synspline
synth
tangent
tapdelay
tesselate
texmchan
texture
tkusage
tkusage_other
topantail2
tostereo
transit
tremenv
tremolo
ts
tsconvert
tunevary
tweet
unknot
vectors
verges
vuform
waveform
wrappage

desktop

my orange pi 5 that ive been using instead of my desktop pc. it is an arm64 singleboard computer with 16gb of ram

firefox + palemoon addons

incase my mountain of computers and hardddrives get destroyed

Firefox

Add custom search engine extension 4.2 true {af37054b-3ace-46a2-ac59-709e4412bec6}
Amazon.co.uk extension 1.9 true amazon@search.mozilla.org
Autofill extension 9.6.6 true {143f479b-4cb2-4d8c-8c31-ae8653bc6054}
Behind The Overlay extension 0.1.6 true jid1-Y3WfE7td45aWDw@jetpack
Bing extension 1.3 true bing@search.mozilla.org
Black New Tab extension 1.0.0 true {3c53fae8-7f6e-4c86-b595-43f97766b977}
Chambers (UK) extension 1.0 true chambers-en-GB@search.mozilla.org
Check4Change extension 2.2.3 true check4change-owner@mozdev.org
Close Tabs to the Left extension 1.0.0 true closetabstotheleft@parkerm.github.io
Context Search extension 4.1.6 true olivier.debroqueville@gmail.com
Cookie Quick Manager extension 0.5rc2 true {60f82f00-9ad5-4de5-b31c-b16a47c51558}
Dark Background and Light Text extension 0.7.6 true jid1-QoFqdK4qzUfGWQ@jetpack
Disconnect extension 20.3.1.1 true 2.0@disconnect.me
DownThemAll! extension 4.2.6 true {DDC359D1-844A-42a7-9AA1-88A850A938A8}
DuckDuckGo extension 1.1 true ddg@search.mozilla.org
eBay extension 1.3 true ebay@search.mozilla.org
Forecastfox (fix version) extension 4.26 true forecastfox@s3_fix_version
Forget Me Not – Forget cookies & other data extension 2.2.8 true forget-me-not@lusito.info
Google extension 1.1 true google@search.mozilla.org
Greasemonkey extension 4.11 true {e4a8a97b-f2ed-450b-b12d-ee082ba24781}
HTTPS Everywhere extension 2021.4.15 true https-everywhere-eff@eff.org
I don’t care about cookies extension 3.3.1 true jid1-KKzOGWgsW3Ao4Q@jetpack
ImageBlock extension 5.0 true imageblock@hemantvats.com
JavaScript Toggle On and Off extension 0.2.4 true {479f0278-2c34-4365-b9f0-1d328d0f0a40}
Nitter Instead extension 2.4 true {3fe116df-848d-4027-9ae8-b298d48eab20}
NoScript extension 11.2.11 true {73a6fe31-595d-460b-a920-fcc0f8843232}
Open Tabs Next to Current extension 2.0.14 true opentabsnexttocurrent@sblask
Open With extension 7.2.5 true openwith@darktrojan.net
QOwnNotes Web Companion extension 21.6.0 true WebCompanion@qownnotes.org
Random Bookmark extension 2.0.12 true random-bookmark@stevenaleong.com
Random Bookmark From Folder extension 2.1 true randombookmark@pikadudeno1.com
Referer Control extension 1.31 true {cde47992-8aa7-4206-9e98-680a2d20f798}
RSSPreview extension 3.15 true {7799824a-30fe-4c67-8b3e-7094ea203c94}
SingleFileZ extension 1.0.29 true {e4db92bc-3213-493d-bd9e-5ff2afc72da6}
Smart HTTPS extension 0.3.1 true {b3e677f4-1150-4387-8629-da738260a48e}
Stylus extension 1.5.19 true {7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}
Tab Reloader (page auto refresh) extension 0.3.7 true jid0-bnmfwWw2w2w4e4edvcdDbnMhdVg@jetpack
uBlock Origin extension 1.37.0 true uBlock0@raymondhill.net
uMatrix extension 1.4.4 true uMatrix@raymondhill.net
UnLazy extension 8.0.6.28 true unlazy-alpha1@eladkarako.com
Update Scanner extension 4.4.0 true {c07d1a49-9894-49ff-a594-38960ede8fb9}
Video DownloadHelper extension 7.6.0 true {b9db16a4-6edc-47ec-a1f4-b86292ed211d}
Vimium C – All by Keyboard extension 1.90.2 true vimium-c@gdh1995.cn
Weather extension 5.0.9 true {a79a9c4c-9c3f-4bf4-9e58-6574cc0b7ecb}
Web Archives extension 2.1.0 true {d07ccf11-c0cd-4938-a265-2a4d6ad01189}
Wikipedia (en) extension 1.1 true wikipedia@search.mozilla.org
Work Offline extension 0.1.4 true {2936ba13-a63a-41cf-a4e5-79274a38379e}
Youtube Audio extension 0.0.2.5 true {580efa7

Add custom search engine extension 4.2 true {af37054b-3ace-46a2-ac59-709e4412bec6}
Add-ons Search Detection extension 2.0.0 true addons-search-detection@mozilla.com
Amazon.co.uk extension 1.9 true amazon@search.mozilla.org
Amazon.com extension 1.3 true amazondotcom@search.mozilla.org
Behind The Overlay extension 0.2.1 true jid1-Y3WfE7td45aWDw@jetpack
Bing extension 1.3 true bing@search.mozilla.org
Black New Tab extension 1.0.0 true {3c53fae8-7f6e-4c86-b595-43f97766b977}
Bypass Paywalls extension 1.7.9 true bypasspaywalls@bypasspaywalls
Chambers (UK) extension 1.0 true chambers-en-GB@search.mozilla.org
Check4Change extension 2.2.4 true check4change-owner@mozdev.org
Context Search extension 4.3.0 true olivier.debroqueville@gmail.com
Currency Converter extension 0.6.9 true {8499351e-6812-4751-9b57-cf16f69fecec}
Dark Background and Light Text extension 0.7.6 true jid1-QoFqdK4qzUfGWQ@jetpack
DuckDuckGo extension 1.1 true ddg@search.mozilla.org
eBay extension 1.3 true ebay@search.mozilla.org
Flagfox extension 6.1.49 true {1018e4d6-728f-4b20-ad56-37578a4de76b}
Forecastfox (fix version) extension 4.26 true forecastfox@s3_fix_version
Forget Me Not – Forget cookies & other data extension 2.2.8 true forget-me-not@lusito.info
Google extension 1.2 true google@search.mozilla.org
Greasemonkey extension 4.11 true {e4a8a97b-f2ed-450b-b12d-ee082ba24781}
I don’t care about cookies extension 3.3.8 true jid1-KKzOGWgsW3Ao4Q@jetpack
Nitter Instead extension 2.5.3 true {3fe116df-848d-4027-9ae8-b298d48eab20}
NoScript extension 11.4.4rc1 true {73a6fe31-595d-460b-a920-fcc0f8843232}
Old Reddit Redirect extension 1.6.0 true {9063c2e9-e07c-4c2c-9646-cfe7ca8d0498}
Open Tabs Next to Current extension 2.0.14 true opentabsnexttocurrent@sblask
Open With extension 7.2.6 true openwith@darktrojan.net
QOwnNotes Web Companion extension 22.2.3 true WebCompanion@qownnotes.org
Random Bookmark extension 2.1.0 true random-bookmark@stevenaleong.com
Random Bookmark From Folder extension 2.1 true randombookmark@pikadudeno1.com
Referer Control extension 1.31 true {cde47992-8aa7-4206-9e98-680a2d20f798}
RSSPreview extension 3.17 true {7799824a-30fe-4c67-8b3e-7094ea203c94}
Sidebery extension 4.10.0 true {3c078156-979c-498b-8990-85f7987dd929}
SingleFileZ extension 1.0.65 true {e4db92bc-3213-493d-bd9e-5ff2afc72da6}
Snap Links extension 3.1.11 true snaplinks@snaplinks.mozdev.org
Stylus extension 1.5.21 true {7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}
Tab Reloader (page auto refresh) extension 0.3.7 true jid0-bnmfwWw2w2w4e4edvcdDbnMhdVg@jetpack
Tab Session Manager extension 6.11.1 true Tab-Session-Manager@sienori
uBlock Origin extension 1.42.0 true uBlock0@raymondhill.net
uMatrix extension 1.4.4 true uMatrix@raymondhill.net
Update Scanner extension 4.4.0 true {c07d1a49-9894-49ff-a594-38960ede8fb9}
Video DownloadHelper extension 7.6.0 true {b9db16a4-6edc-47ec-a1f4-b86292ed211d}
Vimium C – All by Keyboard extension 1.97.0 true vimium-c@gdh1995.cn
Weather extension 5.0.9 true {a79a9c4c-9c3f-4bf4-9e58-6574cc0b7ecb}
Wikipedia (en) extension 1.1 true wikipedia@search.mozilla.org
YouTube High Definition extension 85.0.0 true {7b1bf0b6-a1b9-42b0-b75d-252036438bdc}
Audio Equalizer extension 0.1.6 false {63d150c4-394c-4275-bc32-c464e76a891c}
auto-resume downloads extension 1.0.3 false {07a7e965-fa95-4c07-bc5e-b53930b002bb}
Autofill extension 10.3.2 false {143f479b-4cb2-4d8c-8c31-ae8653bc6054}
Certainly Something (Certificate Viewer) extension 1.2.3 false a2fff151f5ad0ef63cbd7e454e8907c1fa9cc32008f489178775570374f408a7@pokeinthe.io
ColorfulTabs extension 34.8 false {0545b830-f0aa-4d7e-8820-50a4629a56fe}
Cookie Quick Manager extension 0.5rc2 false {60f82f00-9ad5-4de5-b31c-b16a47c51558}
Dark New Tab extension 0.1.2 false {2fc113fc-f01e-427a-8c4a-07b8b2d92f26}
Dictionary Anywhere extension 1.1.0 false {e90f5de4-8510-4515-9f67-3b6654e1e8c2}
Disconnect extension 20.3.1.1 false 2.0@disconnect.me
DownThemAll! extension 4.3.1 false {DDC359D1-844A-42a7-9AA1-88A850A938A8}
Easy to RSS extension 0.2.0 false {45909d54-3dd5-4298-8bb0-8a8d27a333ff}
floccus bookmarks sync extension 4.12.0 false floccus@handmadeideas.org
Hexconverter extension 1.7.0 false {e593c8ed-ae74-4039-af09-dfe4ad243adb}
JavaScript Toggle On and Off extension 0.2.4 false {479f0278-2c34-4365-b9f0-1d328d0f0a40}
Kee – Password Manager extension 3.9.5 false keefox@chris.tomlinson
MetaMask extension 10.11.3 false webextension@metamask.io
Midnight Lizard extension 10.7.1 false {8fbc7259-8015-4172-9af1-20e1edfbbd3a}
Share Button for Facebook™ extension 63.0 false {d4e0dc9c-c356-438e-afbe-dca439f4399d}
SingleFile extension 1.19.35 false {531906d3-e22f-4a6c-a102-8057b88a1a63}
Tabliss extension 2.4.2 false extension@tabliss.io
Tabloc extension 0.8 false {60520222-6bbf-45dd-b547-3641ea9cd9cb}
TinEye Reverse Image Search extension 1.5.2 false tineye@ideeinc.com
Twitter to Nitter Redirect extension 1.0.4 false {806caba7-d957-45dd-a533-7cb334dc2a6c}
UnLazy extension 8.0.6.28 false unlazy-alpha1@eladkarako.com
User-Agent Switcher and Manager extension 0.4.7.1 false {a6c4a591-f1b2-4f03-b3ff-767e5bedf4e7}
Web Archives extension 3.1.0 false {d07ccf11-c0cd-4938-a265-2a4d6ad01189}
Work Offline extension 0.1.4 false {2936ba13-a63a-41cf-a4e5-79274a38379e}
Youtube Audio extension 0.0.2.5 false {580efa7d-66f9-474d-857a-8e2afc6b1181}

Palemoon

[TEST] Add to Search Bar 2.9 true add-to-searchbox@maltekraus.de
[TEST] Addon List Dumper (restartless) 0.2.1-signed.1-signed true addonListDumper@jetpack
[TEST] bug489729(Disable detach and tear off tab) 2.1.1-signed.1-signed true bug489729@alice0775
[TEST] Change Profile’s Window Icons To Aurora 1.1.0 true change-window-icon-aurora@makyen.foo
[TEST] Check4Change 1.9.8.3 true check4change-owner@mozdev.org
[TEST] ColorfulTabs 23.9.1-signed true {0545b830-f0aa-4d7e-8820-50a4629a56fe}
[TEST] CookieCuller 1.4.1-signed.1-signed true {99B98C2C-7274-45a3-A640-D9DF1A1C8460}
[TEST] CountdownClock 1.4.5.1-signed.1-signed true {19D3B002-1AD1-4a69-A5B3-AA98773DBB86}
[TEST] DictionarySearch 28.0.0.1-signed true {a0faa0a4-f1a7-4098-9a74-21efc3a92372}
[TEST] Disconnect 3.15.3.1-signed.1-signed true 2.0@disconnect.me
[TEST] DownThemAll! 3.0.8 true {DDC359D1-844A-42a7-9AA1-88A850A938A8}
[TEST] Foobar Controls 0.3.6.1-signed.1-signed true {F3281C6A-29E3-405D-BD66-614E70C0B6B9}
[TEST] Image-Show-Hide 0.2.8.1.1-signed.1-signed true {92A24891-BA14-4e89-9FFD-07FFBE4334EE}
[TEST] JS Switch 0.2.10.1-signed.1-signed true {88c7b321-2eb8-11da-8cd6-0800200c9a66}
[TEST] Norwell History Tools 3.1.0.2.1-signed.1-signed true norvel@history
[TEST] QuickNote 0.7.6 true {C0CB8BA3-6C1B-47e8-A6AB-1FAB889562D9}
[TEST] Random Bookmark From Folder 1.0.1.1-signed true randombookmark@pikadudeno1.com
[TEST] RefControl 0.8.17.1-signed.1-signed true {455D905A-D37C-4643-A9E2-F6FEFAA0424A}
[TEST] ReminderFox 2.1.6.3 true {ada4b710-8346-4b82-8199-5de2b400a6ae}
[TEST] Update Scanner 3.3.1 true {c07d1a49-9894-49ff-a594-38960ede8fb9}
[TEST] VimFx 0.5.10.1-signed true VimFx@akhodakivskiy.github.com
[TEST] Work Offline 2.2 true {761a54f1-8ccf-4112-9e48-dbf72adf6244}
Add Bookmark Helper 1.0.10 true abh2me@Off.JustOff
Advanced Night Mode 1.0.13 true AdvancedNightMode@Off.JustOff
Age Unlimiter for YouTube 1.0.2 true ageless-yt-me@Off.JustOff
CipherFox 4.2.0 true cipherfox@mkfly
Classic Add-ons Archive 2.0.3 true ca-archive@Off.JustOff
Context Search X 0.4.6.26 true contextsearch2@lwz.addons.mozilla.org
Cookie Masters 3.2.0 true {a04a71f3-ce74-4134-8f86-fae693b19e44}
Crush Those Cookies 1.4.0 true crush-those-cookies@wsdfhjxc
Dismiss The Overlay 1.0.7 true behind-the-overlay-me@Off.JustOff
Expose Noisy Tabs 1.1.1 true expose-noisy-tabs@wsdfhjxc
Greasemonkey for Pale Moon 3.31.4 true greasemonkeyforpm@janekptacijarabaci
Greedy Cache 1.2.3 true greedycache@Off.JustOff
Home Styler 2.0.0 true homestyle@lootyhoof-pm
I don’t care about cookies 3.3.1 true jid1-KKzOGWgsW3Ao4Q@jetpack
JSView Revive 2.1.8 true {55e5dab6-f1cc-11e6-8a72-4981b17b32b7}
Moon Tester Tool 2.1.4 true moonttool@Off.JustOff
MozArchiver 2.0.1 true mozarchiver@lootyhoof-pm
NoScript 5.0.6 true {73a6fe31-595d-460b-a920-fcc0f8843232}
NoSquint 2.2.2 true nosquint@me.ebonjaeger.com
Open With 6.8.6 true openwith@darktrojan.net
Pale Moon Locale Switcher 3.1 true pm-localeswitch@palemoon.org
Reader View 2.2.0 true {1111dd1e-dd02-4c30-956f-f23c44dfea8e}
Snap Links Plus 2.4.3 true snaplinks@snaplinks.mozdev.org
Speed Start 2.1.6 true SStart@Off.JustOff
Stylem 2.2.6 true {503a85e3-84c9-40e5-b98e-98e62085837f}
Tab Mix Plus 0.5.8.1 true {dc572301-7619-498c-a57d-39143191b318}
uBlock Origin 1.16.4.30 true uBlock0@raymondhill.net
uMatrix 1.0.0 true uMatrix@raymondhill.net
View Source In Tab 1.0.3 true vstab@Off.JustOff
[TEST] Random Agent Spoofer 0.9.5.5 false jid1-AVgCeF1zoVzMjA@jetpack
[TEST] Zoom Page 15.8 false zoompage@DW-dev
Auto-Sort Bookmarks 2.10.12 false sortbookmarks@bouanto
BarTab Tycho 4.0 false bartab@infernozeus
BetterPrivacy 1.77 false {d40f5e7b-d2cf-4856-b441-cc613eeffbe3}
Color My Tabs 2.2.0 false color-my-tabs@wsdfhjxc
Complete YouTube Saver 5.7.36.1 false {AF445D67-154C-4c69-A17B-7F392BCC36A3}
Flashblock 1.5.20 false {3d7eb24f-2740-49df-8937-200b1cc08f8a}
Forecastfox (fix version) 2.4.8 false forecastfox@s3_fix_version
HTTPS Everywhere 5.2.21 false https-everywhere@eff.org
Internote 3.0.2.1-signed.1-signed false {e3631030-7c02-11da-a72b-0800200c9a66}
Mozilla Archive Format 5.2.1 false {7f57cf46-4467-4c2d-adfa-0cba7c507e54}
NoteStruck 1.0.4 false notestruck@franklindm
PHP Developer Toolbar 3.0.5.1-signed.1-signed false php_dev_bar@php_dev_bar.org
Popup Dictionaries With Audio 3.0.0 false {efb0aee9-a019-4341-bbeb-11e1630492f3}
Prevent Tab Overflow 7.2 false noverflow@sdrocking.com
Reasy 0.0.14.1-signed.1-signed false {fcff419f-5bfb-40cd-b52c-8f55dc2d0511}
RequestPolicy 0.5.28.1-signed.1-signed false requestpolicy@requestpolicy.com
RightBar 0.5.1-signed.1-signed false rightbar@realmtech.net
Save All Images 1.0.7 false save-images-me@Off.JustOff
Translate This Page, Text, or Link 2.1.0 false {8701e193-7b0a-4871-b1f8-8f89857c46a1}
User Agent Switcher 0.7.3.1-signed.1-signed false {e968fc70-8f95-4ab9-9e79-304de2a71ee1}
YouTube Video Player Pop Out 49.0 false {00f7ab9f-62f4-4145-b2f9-38d579d639f6}
ηMatrix 4.4.9 false eMatrix@vannilla.org

weighted random ambient playlist hotkey for foobar2000

this one is nasty:


global arr := ["ambient", "ambient2", "ambient3", "ambient4", "ambient5", "ambient6", "ambient7", "ambient8", "randambient2", "randambient2"]
Random, oVar, 1, arr.MaxIndex()
rand := arr[oVar]
Run "D:\foobar2000\foobar2000.exe" /runcmd=File/Scheduler/%rand%

foo_scheduler actions:

ambient#
Set playback order to “Repeat (Playlist)”
Set active playlist “ambient”
Start playback from track #123456


randambient2
Set playback order to “Random”
Set active playlist “ambient”
Start playback
1 seconds delay
Next track
1 seconds delay
Set playback order to “Repeat (Playlist)”

Auto